Тестирование, некоторые слышали о нем, некоторые религиозно практикуют TDD, а некоторые просто добавляют тесты ради добавления тестов. Независимо от ваших мотивов, не забывайте об этих распространенных подводных камнях при добавлении тестов:
Изоляция тестов
Вы можете думать, что это нормально — тестировать свой DAO (объект доступа к данным), вставляя в производственную базу данных некоторые данные, делая тест, а затем удаляя тестовые данные. Это не нормально. Вы не только изменяете prod, но и создаете параллельный беспорядок, когда если два экземпляра одного и того же теста будут запущены в одно и то же время, тесты будут проваливаться в случайном порядке.
Поэтому не делайте этого и запускайте свои тесты изолированно друг от друга:
- Тестируете адаптер базы данных? Используйте Testcontainers
- Тестирование в файловой системе? Создайте случайный временный каталог для проведения каждого теста.
- Нужно поиздеваться над сервером? Выберите случайный порт.
Утверждения, которые не являются утверждениями
Если вы видите что-то вроде:
expect(dao.getAllClients().length).toBeGreaterThan(1)
Тогда пришло время переосмыслить тест. Недостаток здесь в том, что вы не проверяете, ЧТО возвращает метод, а только то, что он что-то возвращает. Правильный способ тестирования DAO выглядит следующим образом:
expect(dao.getAllClients()).toStrictEqual([{id: 1, name: "michel"}])
«Но я получаю эти записи из производственной/тестовой базы данных, я не могу заранее контролировать, что вернет база данных».
Тогда, мой друг, вам не хватает старой доброй ИЗОЛЯЦИИ (см. предыдущую главу).
Разделитесь!
Если ваши тесты занимают очень много времени из-за их зависимости от базы данных / стороннего Rest API / необходимости запускать веб-фреймворк для каждого теста / и т.д., то пришло время развязки!
Вам не нужно привязывать всю бизнес-логику вашего приложения к адаптеру базы данных. Вместо этого спрячьте все за интерфейсом. Таким образом, вы можете заменить адаптер на поддельный (двойной тестовый) и имитировать то, что будет возвращать база данных, без необходимости использовать ее на самом деле!
Например, у нас есть служба, которая возвращает клиентов, подходящих для участия в программе верности корпорации (неважно):
export class ClientService {
constructor(private readonly dbClient: MongodbClient) {
}
public selectEligibleClients(): Result {
const clients = this.mongodbClient.clients.findAll();
return clients.map(client => client.fidelityFactor > 0.8)
}
}
Как вы можете видеть, MongoDbClient тесно связан с сервисом. То есть, если мы хотим протестировать сервис, нам нужен экземпляр MongoDB. Сейчас мы собираемся рефакторить этот сервис, чтобы отделить его от клиента MongoDB:
export interface IClientServiceDatabasePort {
getAllClients(): Array<Client>
}
export class ClientServiceMongodbDatabaseAdapter implements IClientServiceDatabasePort {
constructor(private readonly mongodbClient: MongodbClient) {
}
getAllClients(): Array<Client> {
return this.mongodbClient.clients.findAll();
}
}
export class ClientServiceStubDatabaseAdapter implements IClientServiceDatabasePort {
constructor(private readonly clientsToReturn: Array<Client>) {
}
getAllClients(): Array<Client> {
return this.clientsToReturn;
}
}
export class ClientService {
constructor(private readonly dbClient: IClientServiceDatabasePort) {
}
public selectEligibleClients(): Result {
const clients = this.dbClient.getAllClients();
return clients.map(client => client.fidelityFactor > 0.8)
}
}
Теперь мы можем использовать заглушку ClientServiceStubDatabaseAdapter
в наших тестах и тестировать логику независимо от базы данных, и вы также можете тестировать ClientServiceStubDatabaseAdapter
независимо от логики. Я считаю это абсолютной победой!
Слишком много нерелевантного тестирования
Вам не нужно тестировать КАЖДЫЙ метод вашего класса (включая то, что могло бы быть приватным, но что вы сделали публичным ради тестирования), каждый маленький помощник и т.д.
Если вы следуете принципу единой ответственности, ваш класс должен раскрывать только соответствующий публичный API и держать детали своей реализации в тайне.
«Но как мы можем убедиться, что закрытые методы наших классов работают так, как задумано?»
Конечно же, через публичный API! Если в вашем тесте нет явного вызова вашего частного метода, это не значит, что он не тестируется. Например, следующий тест проверяет работу палиндромного экстрактора (он извлекает из строки слова, которые могут быть красными в обе стороны):
it("Empty string is not a palindrome hence nothing is returned", () => {
expect(palindromeExtractor.extract("")).toStrictEqual([])
})
it("Single letter is not a palindrome", () => {
expect(palindromeExtractor.extract("a")).toStrictEqual([])
})
it("Double letter - same", () => {
expect(palindromeExtractor.extract("aa")).toStrictEqual(["aa"])
})
it("Double letter - different", () => {
expect(palindromeExtractor.extract("ab")).toStrictEqual([])
})
it("triple letter - palindrome", () => {
expect(palindromeExtractor.extract("aba")).toStrictEqual(["aba"])
})
it("triple letter - asymmetric", () => {
expect(palindromeExtractor.extract("abc")).toStrictEqual([])
})
it("multiple letter", () => {
expect(palindromeExtractor.extract("sugus")).toStrictEqual(["sugus"])
expect(palindromeExtractor.extract("train")).toStrictEqual([])
})
it("sentences", () => {
expect(palindromeExtractor.extract("I eat sugus and it belongs to Anna"))
.toStrictEqual(["sugus", "Anna"])
})
И реализация экстрактора:
export class PalindromeExtractor {
extract(input: string): Array<string> {
return input.split(" ")
.filter(word => {
if (word.length < 2) {
return false;
}
for (let i = 0, j = word.length - 1; i <= j; i++, j--) {
if (word[i].toLowerCase() !== word[j].toLowerCase()) {
return false
}
}
return true
})
}
}
Мы видим, что единственный метод класса хорошо протестирован для основных случаев (дополнительные были вырезаны для простоты). Теперь допустим, что мы хотим сделать небольшой рефакторинг и перенести в собственный метод код, который проверяет, является ли слово палиндромом:
export class PalindromeExtractor {
extract(input: string): Array<string> {
return input.split(" ")
.filter(word => PalindromeExtractor.isPalindrome(word))
}
private static isPalindrome(word: string): boolean {
if (word.length < 2) {
return false;
}
for (let i = 0, j = word.length - 1; i <= j; i++, j--) {
if (word[i].toLowerCase() !== word[j].toLowerCase()) {
return false
}
}
return true
}
}
Теперь наш код стал немного более читабельным. И, как вы видите, нам не нужно тестировать isPalindrome
, поскольку наши тесты уже охватывают сценарии с одним словом. Так что оставьте все как есть. Этот приватный метод — деталь реализации, и его не нужно тестировать. Мы уже подтвердили, что он работает в тестах экстрактора.
Заключение
Теперь мы научились:
- Выполнять наши тесты изолированно.
- Убедиться, что наши утверждения действительно подтверждают результаты.
- Отделять нашу бизнес-логику от внешних либ, таких как db, файловая система и т.д.
- Мы тестируем только то, что должно быть протестировано.
Вот и все, до следующего раза, друзья!