Разработка команды запроса данных из базы — часть 2
В предыдущей части я остановился на том, что разрабатываемая мной команда реализует поведение, которое можно описать вот таким тестом:
it('execute should return promise', () => {
request.configure(options);
request.execute().then((result) => {
expect(result.Id).toEqual(1);
expect(result.Name).toEqual('Jack');
});
});
Как мне теперь кажется, получать в виде результата Promise
и обрабатывать его, это не совсем то, чего мне хотелось бы. Лучше было бы если бы команда сама выполняла эту рутинную работу, а результат ее помещала бы например в хранилище Redux
. Попробую переписать имеющийся тест, чтобы выразить в нем мои новые ожидания:
const store = require('../../src/store');
const DbMock = require('../mocks/DbMock');
const db = new DbMock();
const Request = require('../../src/storage/Request');
const options = {
tableName: 'users',
query: { Id: 1 }
};
let request = null;
beforeEach(() => {
request = new Request(db, store);
});
it('should dispatch action if record exists', () => {
request.configure(options);
request.execute(() => {
const user = store.getState().user;
expect(user.Id).toEqual(options.query.Id);
expect(user.Name).toEqual('Jack');
});
});
Так пожалуй удобнее, не смотря на то, что мне теперь придется научить метод execute
, класса Request
выполнять метод обратного вызова, если пользователь передаст его в качестве аргумента. Без него не обойтись, потому что внутри execute
я предполагаю использовать асинхронные вызовы, протестировать результаты выполнения которых можно только будучи убежденным что их выполнение завершилось.
Далее… Глядя на первую строчку кода, я понимаю что прежде чем я смогу вернуться к редактированию кода класса Request
, мне нужно добавить в проект пакет Redux
, реализовать для него по меньшей мере один редуктор
и реализовать отдельно упаковку этого редуктора в Store
. Первый тест будет для редуктора пожалуй:
const reduce = require('../../src/reducers/user');
it('should return new state', () => {
const user = { Id: 1, Name: 'Jack'};
const state = reduce(null, {type: 'USER', user });
expect(state).toEqual(user);
});
Запускаю тесты и соглашаюсь с Jasmine
что в дополнение ко всем предыдущим ошибкам, не найден модуль с именем ../../src/reducers/user
. Поэтому напишу его, тем более что он обещает быть крошечным и страшно предсказуемым:
const user = (state = null, action) => {
switch (action.type) {
case 'USER':
return action.user;
default:
return state;
}
};
module.exports = user;
Запускаю тесты и не вижу радикальных улучшений. Это потому что модуль ../../src/store
, существование которого я предположил в тесте для моего класса Request
, я до сих пор не реализовал. Да и теста для него собственно тоже еще нет. Начну конечно же с теста:
describe('store', () => {
const store = require('../src/store');
it('should reduce USER', () => {
const user = { Id: 1, Name: 'Jack' };
store.dispatch({type: 'USER', user });
const state = store.getState();
expect(state.user).toEqual(user);
});
});
Тесты? Сообщений об отсутствии модуля store
стало больше, так что займусь им немедленно.
const createStore = require('redux').createStore;
const combineReducers = require('redux').combineReducers;
const user = require('./reducers/user');
const reducers = combineReducers({user});
const store = createStore(reducers);
module.exports = store;
Понимая что у меня будет не один редуктор, забегаю в реализации хранилища
чуть вперед и использую при его сборке метод combineReducers
. Снова запускаю тесты и вижу новое сообщение об ошибке, которое говорит мне что метод execute
моего класса Request
не работает так, как предполагает мой тест. В результате выполнения метода execute
в хранилище
не появляется запись о пользователе. Настало время рефакторинга класса Request
.
Вспомню как теперь у меня выглядит тест метода execute
:
it('should dispatch action if record exists', () => {
request.configure(options);
request.execute(() => {
const user = store.getState().user;
expect(user.Id).toEqual(options.query.Id);
expect(user.Name).toEqual('Jack');
});
});
И исправлю код самого метода, чтобы у теста появился шанс выполниться:
execute(callback){
const table = this.db.Model.extend({
tableName: this.options.tableName
});
table.where(this.options.query).fetch().then((item) => {
this.store.dispatch({ type: 'USER', user: item });
if(typeof callback === 'function')
callback();
});
}
Наберу в консоли npm test
и… Бинго! Мой запрос научился не только получать данные из базы, но и сохранять их в контейнере состояния
будущего процесса обработки, с тем, чтобы последующие операции могли эти данные без проблем получить.
Но! Мой обработчик умеет диспетчеризовать в контейнер состояния
лишь один тип действия, и это сильно ограничивает его возможности. Я же хочу использовать этот код повторно всякий раз, когда мне надо будет извлечь из базы какую-нибудь запись и диспетчеризовать ее в ячейку контейнера состояния
для последующей обработки под нужным мне ключом. И поэтому я начинаю снова рефакторить тест:
const options = {
tableName: 'users',
query: { Id : 1 },
success (result, store) {
const type = 'USER';
const action = { type , user: result };
store.dispatch(action);
}
};
it('should dispatch action if record exists', () => {
request.configure(options);
request.execute(() => {
const user = store.getState().user;
expect(user.Id).toEqual(options.query.Id);
expect(user.Name).toEqual('Jack');
});
});
Мне пришло в голову что неплохо было бы избавить класс Request
от несвойственной ему функциональности по обработке результатов запроса. Семантически Request
— это запрос. Выполнили запрос, получили ответ, задача выполнена, принцип единственной ответственности класса соблюдается. А обработкой результатов пусть занимается кто-то специально этому обученный, чьей единственной ответственностью предполагается некий вариант собственно обработки. Поэтому я решил в настройки запроса передавать метод success
, на который и возлагается задача обработки успешно возвращенных запросом данных.
Тесты, сейчас можно не запускать. Умом я это понимаю. Я ничего не исправил в самом тесте и ничего не изменил в реализации и тесты должны продолжать успешно выполняться. Но эмоционально мне нужно выполнить команду npm test
и я ее выполняю, и перехожу к редактированию реализации моего метода execute
в классе Request
чтобы заменить строчку с вызовом store.dispatch(...)
, на строчку с вызовом this.options.success(...)
:
execute(callback){
const table = this.db.Model.extend({
tableName: this.options.tableName
});
table.where(this.options.query).fetch().then((item) => {
this.options.success(item, this.store);
if(typeof callback !== 'undefined')
callback();
});
}
Запускаю тесты. Вуаля! Тесты абсолютно зеленые. Жизнь налаживается! Что дальше? Сходу вижу что нужно поменять заголовок теста, потому что он не вполне соответствует действительности. Тест проверяет не то, что в результате запроса происходит диспетчеризация метода, а то, что в результате запроса происходит обновление состояния в контейнере. Поэтому меняю заголовок теста на… ну к примеру:
it('should update store user state if record exists', () => {
request.configure(options);
request.execute(() => {
const user = store.getState().user;
expect(user.Id).toEqual(options.query.Id);
expect(user.Name).toEqual('Jack');
});
});
Что дальше? А дальше я думаю пришло время обратить внимание ни случай, когда вместо запрашиваемых данных мой запрос вернет ошибку. Это же не такой уж невозможный сценарий. Правда? А главное что в этом случае, я не смогу подготовить и отправить требуемый комплект данных моему KYC оператору, ради интеграции с которым я пишу весь этот код. Ведь так? Так. Сначала напишу тест:
it('should add item to store error state', () => {
options.query = { Id: 555 };
options.error = (error, store) => {
const type = 'ERROR';
const action = { type, error };
store.dispatch(action);
};
request.configure(options);
request.execute(() => {
const error = store.getState().error;
expect(Array.isArray(error)).toBeTruthy();
expect(error.length).toEqual(1);
expect(error[0].message).toEqual('Something goes wrong!');
});
});
Не знаю видно ли по структуре теста что я решил сэкономить время и деньги и написать минимум кода, для проверки случая, когда запрос возвращает ошибку? Видно нет?
Я не хочу сейчас тратить время на кодирование дополнительных реализаций TableMock
, которые будут имитировать ошибки. Я решил что в настоящий момент мне вполне хватит пары условных конструкций в имеющейся реализации, и предположил что это можно отрегулировать через параметры запроса query
. Итак, мои предположения:
- Если
Id
в запросеoptions.query
равно 1, то моя псевдотаблица всегда возвращает разрешенныйPromise
с самой первой записью из коллекции. - Если
Id
в запросеoptions.query
равно 555, то моя псевдотаблица всегда возвращает отвергнутыйPromise
с экземпляромError
внутри, содержаниеmessage
которого, равно Something goes wrong! .
Конечно это далеко не идеальный вариант. Гораздо более читабельно и удобно для восприятия было бы реализовать соответствующие экземпляры DbMock
, ну например HealthyDbMock
, FaultyDbMock
, EmptyDbMock
. Из названий которых сразу понятно что первый будет всегда работать правильно, второй будет всегда работать неправильно, а насчет третего можно предположить что он всегда будет вместо результата возвращать null
. Пожалуй что проверив свои первые предположения вышеозначенным способом, что как мне кажется займет минимум времени, я займусь реализацией двух дополнительных экземпляров DbMock
, имитирующих нездоровое поведение.
Запускаю тесты. Получаю ожидаемую ошибку отсутствия нужного мне свойства в контейнере состояния
и… пишу еще один тест. На этот раз для редуктора, который будет обрабатывать действия с типом ERROR
.
describe('error', () => {
const reduce = require('../../src/reducers/error');
it('should add error to state array', () => {
const type = 'ERROR';
const error = new Error('Oooops!');
const state = reduce(undefined, { type, error });
expect(Array.isArray(state)).toBeTruthy();
expect(state.length).toEqual(1);
expect(state.includes(error)).toBeTruthy();
});
});
Снова запускаю тесты. Все ожидаемо, к существующим ошибкам добавилась еще одна. Реализую редуктор
:
const error = (state = [], action) => {
switch (action.type) {
case 'ERROR':
return state.concat([action.error]);
default:
return state;
}
};
module.exports = error;
Опять запускаю тесты. Новый редуктор работает как и предполагалось, однако мне надо еще убедиться что он подключается к хранилищу и обрабатывает действия, для которых он предназначен. Поэтому пишу дополнительный тест для существующего тестового набора хранилища:
it('should reduce error', () => {
const type = 'ERROR';
const error = new Error('Oooops!');
store.dispatch({ type, error });
const state = store.getState();
expect(Array.isArray(state.error)).toBeTruthy();
expect(state.error.length).toEqual(1);
expect(state.error.includes(error)).toBeTruthy();
});
Запускаю тесты. Все ожидаемо. Действие с типом ERROR
имеющееся хранилище не обрабатывает. Дорабатываю существующий код инициализации хранилища:
const createStore = require('redux').createStore;
const combineReducers = require('redux').combineReducers;
const user = require('./reducers/user');
const error = require('./reducers/error');
const reducers = combineReducers({ error, user });
const store = createStore(reducers);
module.exports = store;
В сотый раз закинул он невод… Очень хорошо! Теперь хранилище накапливает полученные сообщения об ошибках в отдельном свойстве контейнера.
Теперь я внесу пару условных конструкций в имеющуюся реализацию TableMock
, научив ее таким образом отфутболивать некоторые запросы возвращая ошибку. Обновленный код выглядит так:
class TableMock {
constructor(array){
this.container = array;
}
where(query){
this.query = query;
return this;
}
fetch(){
return new Promise((resolve, reject) => {
if(this.query.Id === 1)
return resolve(this.container[0]);
if(this.query.Id === 555)
return reject(new Error('Something goes wrong!'));
});
}
}
module.exports = TableMock;
Запускаю тесты и получаю сообщение о необработанном отклонении Promise
-а в методе execute
класса Request
. Дописываю недостающий код:
execute(callback){
const table = this.db.Model.extend({
tableName: this.options.tableName
});
table.where(this.options.query).fetch().then((item) => {
this.options.success(item, this.store);
if(typeof callback === 'function')
callback();
}).catch((error) => {
this.options.error(error, this.store);
if(typeof callback === 'function')
callback();
});
}
И снова запускаю тесты. И??? Нет на самом деле тест для метода execute
, класса Request
, вот этот:
it('should add item to store error state', () => {
options.query = { Id: 555 };
options.error = (error, store) => {
const type = 'ERROR';
const action = { type, error };
store.dispatch(action);
};
request.configure(options);
request.execute(() => {
const error = store.getState().error;
expect(Array.isArray(error)).toBeTruthy();
expect(error.length).toEqual(1);
expect(error[0].message).toEqual('Something goes wrong!');
});
});
Он успешно выполнился. Так что функциональность запроса в части обработки ошибок можно считать реализованной. Упал другой тест, тот который проверяет работоспособность хранилища в части обработки ошибок. Проблема в том, что мой модуль с реализацией хранилища возвращает один и тот же статический экземпляр хранилища всем потребителям во всех тестах. В связи с этим, поскольку уже в двух тестах происходит диспетчеризация ошибок, то в одном из них проверка количества ошибок в контейнере обязательно не проходит. Потому что к моменту запуска теста в контейнере уже есть одна ошибка и в процессе запуска теста туда добавляется еще одна. Поэтому вот этот код:
const error = store.getState().error;
expect(error.length).toEqual(1);
Бросает исключение, сообщая что выражение error.length
на самом деле равно 2, а не 1. Эту проблему прямо сейчас я решу просто переносом кода инициализации хранилища прямо в код инициализации теста хранилища:
describe('store', () => {
const createStore = require('redux').createStore;
const combineReducers = require('redux').combineReducers;
const user = require('../src/reducers/user');
const error = require('../src/reducers/error');
const reducers = combineReducers({ error, user });
const store = createStore(reducers);
it('should reduce USER', () => {
const user = { Id: 1, Name: 'Jack' };
store.dispatch({type: 'USER', user });
const state = store.getState();
expect(state.user).toEqual(user);
});
it('should reduce error', () => {
const type = 'ERROR';
const error = new Error('Oooops!');
store.dispatch({ type, error });
const state = store.getState();
expect(Array.isArray(state.error)).toBeTruthy();
expect(state.error.length).toEqual(1);
expect(state.error.includes(error)).toBeTruthy();
});
});
Код инициализации теста выглядит теперь несколько опухшим, но к его рефакторингу я вполне могу вернуться позже.
Запускаю тесты. Вуаля! Все тесты выполнились и можно сделать перерыв.