В настоящий момент занимаюсь реализацией взаимодействия с поставщиком 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');
});
});
Тесты выполнились успешно. И теперь можно сделать перерыв, а затем вернуться к доработке получившегося кода запроса, потому что он еще весьма и весьма далек не то что от совершенства, но даже и от просто удобного в использовании интерфейса, не смотря на то, что данные с его помощью из базы уже можно получать.