Что такое разработка через тестирование?
Разработка через тестирование (TDD) просто означает, что вы сначала пишете свои тесты. Вы заранее устанавливаете ожидания для правильного кода еще до того, как написали единственную строку бизнес-логики. TDD не только помогает убедиться, что ваш код корректен, но также помогает писать меньшие функции, реорганизовывать код без нарушения функциональности и лучше понимать вашу проблему.
В этой статье я расскажу о некоторых понятиях TDD, создав небольшую утилиту. Мы также рассмотрим некоторые практические сценарии, в которых TDD сделает вашу жизнь проще.
Создание HTTP-клиента с TDD
Что мы будем строить
Мы будем постепенно создавать простой HTTP-клиент, который абстрагирует различные HTTP-глаголы. Чтобы сделать рефакторы гладкими, мы будем следовать методам TDD. Мы будем использовать Жасмин, Синон и Карму для тестирования. Для начала скопируйте package.json , karma.conf.js и webpack.test.js из примера проекта или просто клонируйте пример проекта из репозитория GitHub .
Помогает, если вы понимаете, как работает новый Fetch API, но примеры должны быть простыми для понимания. Для непосвященных, Fetch API является лучшей альтернативой XMLHttpRequest. Это упрощает сетевое взаимодействие и хорошо работает с Promises.
Обертка над GET
Сначала создайте пустой файл в src / http.js и соответствующий тестовый файл в src / __ tests __ / http-test.js .
Давайте настроим тестовую среду для этого сервиса.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
|
import * as http from «../http.js»;
import sinon from «sinon»;
import * as fetch from «isomorphic-fetch»;
describe(«TestHttpService», () => {
describe(«Test success scenarios», () => {
beforeEach(() => {
stubedFetch = sinon.stub(window, «fetch»);
window.fetch.returns(Promise.resolve(mockApiResponse()));
function mockApiResponse(body = {}) {
return new window.Response(JSON.stringify(body), {
status: 200,
headers: { «Content-type»: «application/json» }
});
}
});
});
});
|
Здесь мы используем как Жасмин, так и Синон — Жасмин для определения сценариев тестирования и Синон для утверждения и слежки за объектами. (У Жасмин есть свой собственный способ шпионить и заглушки на тестах, но мне больше нравится API Синона.)
Приведенный выше код не требует пояснений. Перед каждым запуском теста мы перехватываем вызов API Fetch, так как сервер недоступен, и возвращаем объект фиктивного обещания. Цель здесь — провести модульное тестирование, если API Fetch вызывается с правильными параметрами, и посмотреть, способна ли оболочка правильно обрабатывать любые сетевые ошибки.
Давайте начнем с неудачного теста:
1
2
3
4
5
6
7
8
9
|
describe(«Test get requests», () => {
it(«should make a GET request», done => {
http.get(url).then(response => {
expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();
expect(response).toEqual({});
done();
});
});
});
|
Запустите своего бегуна, вызвав karma start
. Теперь тесты, очевидно, не пройдут, поскольку в http
нет метода get
. Давайте исправим это.
01
02
03
04
05
06
07
08
09
10
11
12
|
const status = response => {
if (response.ok) {
return Promise.resolve(response);
}
return Promise.reject(new Error(response.statusText));
};
export const get = (url, params = {}) => {
return fetch(url)
.then(status);
};
|
Если вы сейчас запустите свои тесты, вы увидите неудавшийся ответ, в котором говорится, что Expected [object Response] to equal Object({ })
. Ответ является объектом Stream . Потоковые объекты, как следует из названия, представляют собой поток данных. Чтобы получить данные из потока, вам нужно сначала прочитать поток, используя некоторые из его вспомогательных методов. Сейчас мы можем предположить, что потоком будет JSON, и десериализовать его, вызвав response.json()
.
1
2
3
4
5
6
7
8
|
const deserialize = response => response.json();
export const get = (url, params = {}) => {
return fetch(url)
.then(status)
.then(deserialize)
.catch(error => Promise.reject(new Error(error)));
};
|
Наш тестовый набор теперь должен быть зеленым.
Добавление параметров запроса
Пока что метод get
просто выполняет простой вызов без каких-либо параметров запроса. Давайте напишем провальный тест, чтобы увидеть, как он должен работать с параметрами запроса. Если мы передадим { users: [1, 2], limit: 50, isDetailed: false }
качестве параметров запроса, наш HTTP-клиент должен выполнить сетевой вызов /api/v1/users/?users=1&users=2&limit=50&isDetailed=false
,
01
02
03
04
05
06
07
08
09
10
11
12
|
it(«should serialize array parameter», done => {
const users = [1, 2];
const limit = 50;
const isDetailed = false;
const params = { users, limit, isDetailed };
http
.get(url, params)
.then(response => {
expect(stubedFetch.calledWith(`${url}?isDetailed=false&limit=50&users=1&users=2/`)).toBeTruthy();
done();
})
});
|
Теперь, когда у нас есть настроенный тест, давайте расширим наш метод get
для обработки параметров запроса.
01
02
03
04
05
06
07
08
09
10
11
|
import { stringify } from «query-string»;
export const get = (url, params) => {
const prefix = url.endsWith(‘/’) ?
const queryString = params ?
return fetch(`${prefix}${queryString}`)
.then(status)
.then(deserializeResponse)
.catch(error => Promise.reject(new Error(error)));
};
|
Если параметры присутствуют, мы создаем строку запроса и добавляем ее в URL.
Здесь я использовал библиотеку строк запросов — это симпатичная маленькая вспомогательная библиотека, которая помогает в обработке различных сценариев параметров запроса.
Обработка мутаций
GET, пожалуй, самый простой из реализуемых методов HTTP. GET идемпотентен, и его не следует использовать для каких-либо мутаций. POST обычно предназначен для обновления некоторых записей на сервере. Это означает, что для запросов POST по умолчанию требуются защитные ограждения, например токен CSRF. Подробнее об этом в следующем разделе.
Давайте начнем с создания теста для базового запроса POST:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
|
describe(`Test post requests`, () => {
it(«should send request with custom headers», done => {
const postParams = {
users: [1, 2]
};
http.post(url, postParams, { contentType: http.HTTP_HEADER_TYPES.text })
.then(response => {
const [uri, params] = […stubedFetch.getCall(0).args];
expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();
expect(params.body).toEqual(JSON.stringify(postParams));
expect(params.headers.get(«Content-Type»)).toEqual(http.HTTP_HEADER_TYPES.text);
done();
});
});
});
|
Подпись для POST очень похожа на GET. Он принимает свойство options
, в котором вы можете определить заголовки, тело и, что наиболее важно, method
. Метод описывает HTTP-глагол — в данном случае "post"
.
А пока давайте предположим, что тип контента — JSON, и начнем реализацию запроса POST.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
|
export const HTTP_HEADER_TYPES = {
json: «application/json»,
text: «application/text»,
form: «application/x-www-form-urlencoded»,
multipart: «multipart/form-data»
};
export const post = (url, params) => {
const headers = new Headers();
headers.append(«Content-Type», HTTP_HEADER_TYPES.json);
return fetch(url, {
headers,
method: «post»,
body: JSON.stringify(params),
});
};
|
На данный момент наш метод post
очень примитивен. Он не поддерживает ничего, кроме запроса JSON.
Альтернативные типы контента и токены CSRF
Давайте позволим вызывающей стороне выбрать тип контента и добавим токен CSRF в бой. В зависимости от ваших требований, вы можете сделать CSRF необязательным. В нашем случае использования мы будем предполагать, что это опция отказа, и позвольте вызывающему абоненту определить, нужно ли вам устанавливать токен CSRF в заголовке.
Для этого начните с передачи объекта параметров в качестве третьего параметра нашему методу.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
|
it(«should send request with CSRF», done => {
const postParams = {
users: [1, 2 ]
};
http.post(url, postParams, {
contentType: http.HTTP_HEADER_TYPES.text,
includeCsrf: true
}).then(response => {
const [uri, params] = […stubedFetch.getCall(0).args];
expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();
expect(params.body).toEqual(JSON.stringify(postParams));
expect(params.headers.get(«Content-Type»)).toEqual(http.HTTP_HEADER_TYPES.text);
expect(params.headers.get(«X-CSRF-Token»)).toEqual(csrf);
done();
});
});
|
Когда мы предоставляем options
с {contentType: http.HTTP_HEADER_TYPES.text,includeCsrf: true
, он должен соответственно установить заголовок содержимого и заголовки CSRF. Давайте обновим функцию post
для поддержки этих новых опций.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
|
export const post = (url, params, options={}) => {
const {contentType, includeCsrf} = options;
const headers = new Headers();
headers.append(«Content-Type», contentType || HTTP_HEADER_TYPES.json());
if (includeCsrf) {
headers.append(«X-CSRF-Token», getCSRFToken());
}
return fetch(url, {
headers,
method: «post»,
body: JSON.stringify(params),
});
};
const getCsrfToken = () => {
//This depends on your implementation detail
//Usually this is part of your session cookie
return ‘csrf’
}
|
Обратите внимание, что получение токена CSRF — это деталь реализации. Обычно это часть вашего сеансового cookie, и вы можете извлечь его оттуда. Я не буду освещать это далее в этой статье.
Ваш набор тестов должен быть счастлив.
Формы кодирования
Наш метод post
сейчас обретает форму, но он все еще тривиален при отправке тела. Вам придется обрабатывать данные по-разному для каждого типа контента. При работе с формами мы должны закодировать данные в виде строки перед отправкой по сети.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
|
it(«should send a form-encoded request», done => {
const users = [1, 2];
const limit = 50;
const isDetailed = false;
const postParams = { users, limit, isDetailed };
http.post(url, postParams, {
contentType: http.HTTP_HEADER_TYPES.form,
includeCsrf: true
}).then(response => {
const [uri, params] = […stubedFetch.getCall(0).args];
expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();
expect(params.body).toEqual(«isDetailed=false&limit=50&users=1&users=2»);
expect(params.headers.get(«Content-Type»)).toEqual(http.HTTP_HEADER_TYPES.form);
expect(params.headers.get(«X-CSRF-Token»)).toEqual(csrf);
done();
});
});
|
Давайте извлечем небольшой вспомогательный метод для выполнения этой тяжелой работы. Основываясь на contentType
, он обрабатывает данные по-разному.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
const encodeRequests = (params, contentType) => {
switch (contentType) {
case HTTP_HEADER_TYPES.form: {
return stringify(params);
}
default:
return JSON.stringify(params);
}
}
export const post = (url, params, options={}) => {
const {includeCsrf, contentType} = options;
const headers = new Headers();
headers.append(«Content-Type», contentType || HTTP_HEADER_TYPES.json);
if (includeCsrf) {
headers.append(«X-CSRF-Token», getCSRFToken());
}
return fetch(url, {
headers,
method=»post»,
body: encodeRequests(params, contentType || HTTP_HEADER_TYPES.json)
}).then(deserializeResponse)
.catch(error => Promise.reject(new Error(error)));
};
|
Посмотри на это! Наши тесты все еще проходят даже после рефакторинга основного компонента.
Обработка запросов PATCH
Другой часто используемый HTTP-глагол — PATCH. Теперь PATCH является мутативным вызовом, что означает, что его сигнатура этих двух действий очень похожа. Единственная разница в глаголе HTTP. Мы можем повторно использовать все тесты, которые мы написали для POST, с помощью простой настройки.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
|
[‘post’, ‘patch’].map(verb => {
describe(`Test ${verb} requests`, () => {
let stubCSRF, csrf;
beforeEach(() => {
csrf = «CSRF»;
stub(http, «getCSRFToken»).returns(csrf);
});
afterEach(() => {
http.getCSRFToken.restore();
});
it(«should send request with custom headers», done => {
const postParams = {
users: [1, 2]
};
http[verb](url, postParams, { contentType: http.HTTP_HEADER_TYPES.text })
.then(response => {
const [uri, params] = […stubedFetch.getCall(0).args];
expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();
expect(params.body).toEqual(JSON.stringify(postParams));
expect(params.headers.get(«Content-Type»)).toEqual(http.HTTP_HEADER_TYPES.text);
done();
});
});
it(«should send request with CSRF», done => {
const postParams = {
users: [1, 2 ]
};
http[verb](url, postParams, {
contentType: http.HTTP_HEADER_TYPES.text,
includeCsrf: true
}).then(response => {
const [uri, params] = […stubedFetch.getCall(0).args];
expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();
expect(params.body).toEqual(JSON.stringify(postParams));
expect(params.headers.get(«Content-Type»)).toEqual(http.HTTP_HEADER_TYPES.text);
expect(params.headers.get(«X-CSRF-Token»)).toEqual(csrf);
done();
});
});
it(«should send a form-encoded request», done => {
const users = [1, 2];
const limit = 50;
const isDetailed = false;
const postParams = { users, limit, isDetailed };
http[verb](url, postParams, {
contentType: http.HTTP_HEADER_TYPES.form,
includeCsrf: true
}).then(response => {
const [uri, params] = […stubedFetch.getCall(0).args];
expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();
expect(params.body).toEqual(«isDetailed=false&limit=50&users=1&users=2»);
expect(params.headers.get(«Content-Type»)).toEqual(http.HTTP_HEADER_TYPES.form);
expect(params.headers.get(«X-CSRF-Token»)).toEqual(csrf);
done();
});
});
});
});
|
Точно так же мы можем повторно использовать текущий метод post
, сделав настраиваемый глагол, и переименовать имя метода, чтобы отразить что-то общее.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
|
const request = (url, params, options={}, method=»post») => {
const {includeCsrf, contentType} = options;
const headers = new Headers();
headers.append(«Content-Type», contentType || HTTP_HEADER_TYPES.json);
if (includeCsrf) {
headers.append(«X-CSRF-Token», getCSRFToken());
}
return fetch(url, {
headers,
method,
body: encodeRequests(params, contentType)
}).then(deserializeResponse)
.catch(error => Promise.reject(new Error(error)));
};
export const post = (url, params, options = {}) => request(url, params, options, ‘post’);
|
Теперь, когда все наши тесты POST пройдены, осталось только добавить еще один метод для patch
.
1
|
export const patch = (url, params, options = {}) => request(url, params, options, ‘patch’);
|
Просто, правда? В качестве упражнения попробуйте добавить запрос PUT или DELETE самостоятельно. Если вы застряли, смело обращайтесь к репо.
Когда в TDD?
Сообщество разделено на это. Некоторые программисты бегут и прячутся в тот момент, когда слышат слово TDD, а другие живут им. Вы можете достичь некоторых из полезных эффектов TDD, просто имея хороший набор тестов. Здесь нет правильного ответа. Все дело в том, насколько вам и вашей команде комфортно с вашим подходом.
Как правило, я использую TDD для сложных, неструктурированных проблем, которые мне нужно прояснить. Оценивая подход или сравнивая несколько подходов, я считаю полезным определить постановку задачи и заранее ее границы. Это помогает в кристаллизации требований и крайних случаев, с которыми должна работать ваша функция. Если количество случаев слишком велико, это говорит о том, что ваша программа может делать слишком много вещей, и, возможно, пришло время разделить ее на более мелкие единицы. Если требования просты, я пропускаю TDD и добавляю тесты позже.
Завершение
В этой теме много шума, и легко потеряться. Если я могу оставить вам некоторый прощальный совет: не слишком беспокоиться о самом TDD, но сосредоточиться на основополагающих принципах. Все дело в написании чистого, понятного и понятного кода. TDD — полезный навык в инструментальном поясе программиста. Со временем вы выработаете интуицию о том, когда применять это.
Спасибо за чтение, и дайте нам знать ваши мысли в разделе комментариев.