В настоящий момент занимаюсь реализацией взаимодействия с поставщиком KYC услуг. Как обычно ничего космического. Нужно просто выбрать из своей базы данных некий достаточно объемный комплект экземпляров различных записей, выгрузить их поставщику услуг и попросить поставщика записи эти проверить.
Начальная стадия обработки содержит десяток идентичных операций с отправкой запросов на извлечение данных некоего конкретного пользователя из различных таблиц базы. Есть предположение что при этом достаточно большую часть кода можно будет использовать повторно в виде абстракции Request. Попробую предположить как этим можно было бы пользоваться. Напишу первый тест:
describe('Request', () => { const Request = require('./Request'); it('execute should return promise', () => { const request = new Request(); request.execute().then((result) => { expect(result).toBeNull(); }); }); });
Выглядит вроде неплохо? Возможно неидеально, но на первый взгляд похоже что Request это по сути команда, которая возвращает Promise с результатом? С этого вполне можно начать. Набросаю код, чтобы тест можно было запустить.
class Request { constructor(){} execute(){ return new Promise((resolve, reject) => { resolve(null); }); } } module.exports = Request;
Выполняю npm test и наблюдаю в консоли зеленую точку выполнившегося теста.
Итак. У меня есть запрос, и он умеет выполнятся. В реальности однако, мне надо будет каким-то образом информировать мой запрос о том, в какой таблице ему следует искать нужные данные и каким критериям эти данные должны соответствовать. Попробую написать новый тест:
it('should configure request', () => { const options = { tableName: 'users', query: { id: 1 } }; request.configure(options); expect(request.options).toEqual(options); });
Нормально? На мой взгляд вполне. Поскольку у меня есть теперь уже два теста, в которых используется экземпляр переменной request, то инициализацию этой переменной я вынесу в специальный метод, выполняющийся перед запуском каждого теста. Таким образом в каждом тесте у меня будет свежий экземпляр объекта запроса:
let request = null; beforeEach(() => { request = new Request(); });
Реализую этот функционал в классе запроса, добавлю в него метод, который сохраняет настройки в переменной экземпляра класса, как это и демонстрирует тест.
configure(options){ this.options = options; }
Запускаю выполнение тестов и вижу теперь уже две зеленых точки. Два моих теста успешно выполнились. Однако. Предполагается однако что запросы мои будут адресоваться базе данных. Сейчас уже пожалуй стоит попробовать посмотреть с какой стороны запрос будет получать информацию о базе данных. Вернусь к тестам и напишу какой-нибудь код:
const DbMock = require('./DbMock'); let db = null; beforeEach(() => { db = new DbMock(); request = new Request(db); });
Мне кажется такой вот классический вариант инициализации через конструктор вполне удовлетворяет моим текущим требованиям.
Естественно я не собираюсь в модульных тестах использовать интерфейс к реальной базе данных MySQL, с которой работает наш проект. Почему? Потому что:
- Если вместо меня, кому то из моих коллег потребуется поработать над этой частью проекта, и выполнить модульные тесты то прежде, чем они смогут хоть что-нибудь сделать, им придется потратить силы и время на установку, и настройку собственного экземпляра сервера MySQL.
- Успешность выполнения модульных тестов, будет зависеть от правильности предварительного заполнения данными, используемой базы сервера MySQL.
- Время запуска тестов с использованием базы данных MySQL будет значительно более длительным.
Ладно. А почему бы к примеру не задействовать в модульных тестах какую-нибудь базу данных в памяти? Работать она будет быстро, а процесс ее настройки и инициализации можно автоматизировать. Все так, но в настоящий момент я не вижу никаких преимуществ от использования этого дополнительного инструмента. Мне кажется что мои текущие потребности быстрее и дешевле (не нужно тратить время на изучение) можно удовлетворить с помощью классов и методов заглушек и псевдообъектов, которые будут лишь имитировать поведение интерфесов, которые предполагается использовать в боевых условиях.
Кстати. В боевых условиях я предполагаю использовать bookshelf в связке с knex. Почему? Потому что следуя документации по установке, настройке и использованию этих двух инструментов, мне удалось за несколько минут создать и выполнить запрос к базе данных.
Что из этого следует? Из этого следует что я должен доработать код класса Request так, чтобы выполнение запроса соответствовало интерфейсам, экспортируемым моим боевым инструментам. А значит теперь код должен выглядеть вот так:
class Request { constructor(db){ this.db = db; } configure(options){ this.options = options; } execute(){ const table = this.db.Model.extend({ tableName: this.options.tableName }); return table.where(this.options.query).fetch(); } } module.exports = Request;
Запущу тесты и посмотрю что происходит. Ага. У меня конечно же отсутствует модуль DbMock, так что первым делом реализую для него заглушку:
class DbMock { constructor(){} } module.exports = DbMock;
Запущу тесты еще раз. Что теперь? Теперь принцесса Jasmine сообщает мне что мой DbMock не реализует свойство Model. Попробую придумать что-нибудь:
class DbMock { constructor(){ this.Model = { extend: () => {} }; } } module.exports = DbMock;
Снова запускаю тесты. Теперь ошибка в том, что в моем модульном тесте, я запускаю выполнение запроса, не выполнив предварительно настройку его параметров с помощью метода configure. Исправляю это:
const options = { tableName: 'users', query: { id: 1 } }; it('execute should return promise', () => { request.configure(options); request.execute().then((result) => { expect(result).toBeNull(); }); });
Поскольку экземпляр переменной options у меня используется уже в двух тестах, то я выношу его в код инициализации всего тестового набора и снова запускаю выполнение тестов.
Как и предполагалось, метод extend, свойства Model, класса DbMock вернул нам undefined, в связи с этим естественно у нашего запроса нет никакой возможности вызвать метод where.
Я уже сейчас понимаю что свойство Model, класса DbMock — следует реализовать за пределами собственно класса DbMock. В первую очередь из-за того, что реализация заглушек необходимых чтобы имеющиеся тесты выполнились, потребует слишком большого количества вложенных областей видимости при инициализации свойства Model прямо в классе DbMock. Это будет совершенно невозможно читать и понимать… И это однако не остановит меня от такой попытки, потому что я хочу убедиться что у меня по-прежнему есть возможность написать всего несколько строчек кода и заставить тесты выполниться успешно.
Итак. Вдох, выдох, слабонервным покинуть помещение. Дополняю реализацию конструктора DbMock. Та-даааамммм....
class DbMock { constructor(){ this.Model = { extend: () => { return { where: () => { return { fetch: () => { return new Promise((resolve, reject) => { resolve(null); }); } }; } }; } }; } } module.exports = DbMock;
Жесть! Однако твердой рукой запускаем тесты и убеждаемся что Jasmine снова показывает нам зеленые точки. А это значит мы все еще на правильном пути, хотя кое-что у нас непозволительно опухло.
Что дальше? Невооруженным глазом видно что свойство Model псевдо-базы данных должно быть реализовано как нечто совершенно отдельное. Хотя навскидку и не понятно как оно должно быть реализовано.
Зато я совершенно точно знаю что записи в этой псевдо-базе прямо сейчас я буду хранить в самых обыкновенных массивах. И поскольку для имеющихся тестов мне нужна только имитация таблицы users, то для начала я реализую массив пользователей, с одной записью. Но для начала, напишу тест:
describe('Users', () => { const users = require('./Users'); it('should contain one user', () => { expect(Array.isArray(users)).toBeTruthy(); expect(users.length).toEqual(1); const user = users[0]; expect(user.Id).toEqual(1); expect(user.Name).toEqual('Jack'); }); });
Запускаю тесты. Убеждаюсь что они не проходят, и реализую свой нехитрый контейнер с пользователем:
const Users = [ { Id: 1, Name: 'Jack' } ]; module.exports = Users;
Теперь тесты выполняются, а мне приходит в голову что семантически Model, в пакете bookshell это поставщик интерфейса доступа к содержимому таблицы в базе. Не зря же мы в метод extend передаем объект с именем таблицы. Почему он называется extend, а не к примеру get, я не знаю. Возможно это просто недостаток знаний об API bookshell.
Ну да бог с ним, ибо теперь у меня в голове есть идея на тему следующего теста:
describe('TableMock', () => { const container = require('./Users'); const Table = require('./TableMock'); const users = new Table(container); it('should return first item', () => { users.fetch({ Id: 1 }).then((item) => { expect(item.Id).toEqual(1); expect(item.Name).toEqual('Jack'); }); }); });
Поскольку в моменте мне нужна реализация, лишь имитирующая функциональность реального драйвера хранилища, то я классы я именую соответствующим образом, добавляя суффикс Mock:
class TableMock { constructor(container){ this.container = container; } fetch() { return new Promise((resolve, reject) => { resolve(this.container[0]); }); } } module.exports = TableMock;
Но fetch не единственный метод, который я предполагаю использовать в боевой версии, так что добавляю еще один тест:
it('where-fetch chain should return first item', () => { users.where({ Id: 1 }).fetch().then((item)=> { expect(item.Id).toEqual(1); expect(item.Name).toEqual('Jack'); }); });
Запуск которого, как и положено отображает мне сообщение об ошибке. Так что дополняю реализацию TableMock, методом where:
where(){ return this; }
Теперь тесты выполняются и можно переходить к размышлениям на тему реализации свойства Model в классе DbMock. Как я уже предполагал, это будет некий поставщик экземпляров объектов типа TableMock:
describe('TableMockMap', () => { const TableMock = require('./TableMock'); const TableMockMap = require('./TableMockMap'); const map = new TableMockMap(); it('extend should return existent TableMock', () => { const users = map.extend({tableName: 'users'}); expect(users instanceof TableMock).toBeTruthy(); }); });
Почему TableMockMap, потому что семантически это оно и есть. Просто вместо имени метода get, используется имя метода extend.
Поскольку тест падает, делаем реализацию:
const Users = require('./Users'); const TableMock = require('./TableMock'); class TableMockMap extends Map{ constructor(){ super(); this.set('users', Users); } extend(options){ const container = this.get(options.tableName); return new TableMock(container); } } module.exports = TableMockMap;
Запускаем тесты и видим шесть зеленых точек в консоли. Жизнь прекрасна.
Как мне кажется прямо сейчас уже можно избавиться от страшной пирамиды инициализации в конструкторе класса DbMock, воспользовавшись замечательным TableMockMap. Не будем откладывать это, тем более что неплохо бы уже было бы выпить чаю. Новая реализация восхитительно изящна:
const TableMockMap = require('./TableMockMap'); class DbMock { constructor(){ this.Model = new TableMockMap(); } } module.exports = DbMock;
Запускаем тесты… и упс! Наш самый главный тест падает. Но это даже и хорошо, потому что это был тест-заглушка и теперь мы просто обязаны его исправить:
it('execute should return promise', () => { request.configure(options); request.execute().then((result) => { expect(result.Id).toEqual(1); expect(result.Name).toEqual('Jack'); }); });
Тесты выполнились успешно. И теперь можно сделать перерыв, а затем вернуться к доработке получившегося кода запроса, потому что он еще весьма и весьма далек не то что от совершенства, но даже и от просто удобного в использовании интерфейса, не смотря на то, что данные с его помощью из базы уже можно получать.
