Разработка команды запроса данных из базы — часть 2

в 14:25, , рубрики: javascript, tdd, гибкое проектирование, Проектирование и рефакторинг, рефакторинг

В предыдущей части я остановился на том, что разрабатываемая мной команда реализует поведение, которое можно описать вот таким тестом:

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();
  });
});

Код инициализации теста выглядит теперь несколько опухшим, но к его рефакторингу я вполне могу вернуться позже.

Запускаю тесты. Вуаля! Все тесты выполнились и можно сделать перерыв.

Автор: SergeyEgorov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js