Jest — это фреймворк модульного тестирования JavaScript с открытым исходным кодом, созданный компанией Facebook и основанный на фреймворке Jasmine. Его отличием от конкурента Jasmine будет популярность, гибкость и скорость выполнения.
Общие сведения: Данная статья посвящена различным примерам рассуждений, ожиданий и матчеров для модульных тестов с Jest в среде, использующей фреймворк Angular SPA.
Мотивация Существует мало материалов, которые объясняют построчно сборку набора и написание сложных тестов.
Область применения: Эта статья рекомендуется пользователям, которые уже имеют концептуальную базу по теме модульных тестов в компонентах. Приведенные здесь примеры сложны, не доступны в репозитории, а также не посвящены установке инструмента, поэтому данный материал считается дополнительным к вводному пониманию фреймворка Jest. Тем не менее, была выстроена логическая структура, которая начинается с начальных концепций, подробно описывает сборку тестового набора в компоненте и заканчивается написанием/выполнением спецификации, фокусируясь на метрике увеличения тестового покрытия в SonarQube.
Цель: здесь мы проедем от 0 до 100 км очень быстро. Показывает, как планировать и писать спецификации, чтобы к концу вы могли действовать самостоятельно.
Установка
Я рекомендую установить помимо Jest еще и Jest-CLI, чтобы построить более подробный сценарий выполнения тестов, отвечающий вашим потребностям, ниже приведена ссылка для установки:
https://jestjs.io/pt-BR/docs/getting-started
В следующих темах будут объяснены некоторые важные концепции для конфигурации и написания модульных тестов.
Набор тестов
Они служат для определения масштаба того, что тестируется.
- Внутри приложения есть несколько наборов тестов;
- Примерами комплектов могут быть: математические расчеты, регистрация клиентов, запрос зарегистрированных…
- В Jest набор — это глобальная функция Javascript под названием
describe
, которая имеет два параметра, которыми являются описание и тесты (specs).
Пример:
describe("Operação de Adição", () => { });
Технические характеристики
- Спецификации — это тесты, которые проверяют набор тестов;
- Как и сьюты, это глобальная функция Javascript под названием ‘it’, которая содержит два параметра, описание и функцию, соответственно;
- Во втором параметре мы добавляем проверки (ожидания).
Пример:
it("deve garantir que 1 + 9 = 10", () => { });
Ожидания
- Проверки служат для подтверждения результатов тестирования;
- В Jest есть глобальная функция Javascript под названием ‘expect’, которая принимает один параметр в качестве аргумента, который является результатом, подлежащим проверке;
- ‘expect’ должен использоваться в сочетании со сравнением (Matcher), которое будет содержать сравниваемое значение;
- Спецификация может содержать одну или несколько проверок;
- Хорошая практика заключается в том, чтобы всегда сохранять чеки по окончании функции.
Пример:
expect(Calculadora.adicionar(1, 9)).toBe(10);
Конфигурация тестового пакета
При написании тестов перед их запуском необходимо выполнить определенную работу по настройке. Если есть что-то, что должно быть выполнено многократно до или после для многих тестов, вы можете использовать hooks
. Для данного примера мы будем использовать функцию, предоставляемую Jest: beforeEach
, которая в основном будет повторять все, что обернуто ею, перед каждым выполненным тестом.
Как только мы создаем новый компонент с помощью angular CLI, автоматически генерируется файл spec с базовой конфигурацией. В соответствии с приведенным ниже кодом:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NovoComponent } from './novo.component';
import { NovoModule } from './novo.module';
describe('NovoComponent', () => {
let component: NovoComponent;
let fixture: ComponentFixture<NovoComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ NovoModule ],
declarations: [],
providers: []
})
.compileComponents();
fixture = TestBed.createComponent(NovoComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
Проанализируйте приведенный выше код. Мы заметили использование describe
для создания набора тестов для NewComponent
, мы видим, что есть две объявленные переменные component
и fixture
, в первой «typing» — имя класса, который был создан, во второй используется componentFixture
для доступа к DOM, отладки и тестирования компонента. В следующей команде находится функция beforeEach
, уже описанная ранее. Согласно конвенции Angular, мы принимаем, что каждый компонент должен обязательно содержаться в модуле, поэтому в структуре beforeEach
мы всегда будем импортировать модуль, в котором объявлен тестируемый компонент. Вы должны добавить в providers
зависимости, которые внедряются в файл typescript.
При необходимости классы, расширяющие инжектированные зависимости, будут добавлены в провайдеры.
После компиляции этих компонентов с помощью compileComponents()
, мы используем TestBed
, который создает тестовый модуль Angular, который мы можем использовать для инстанцирования компонентов, выполнения инъекции зависимостей, чтобы настроить и инициализировать среду для тестирования. В следующей строке кода componentInstance
используется для доступа к экземпляру класса корневого компонента, а fixture
является оберткой для компонента и его шаблона. Функция fixture.detectChanges()
будет срабатывать при любых изменениях, происходящих в DOM.
Наконец, модульные тесты будут добавлены с помощью структуры «it». В приведенном выше коде мы видим пример стандартного модульного теста, который проверяет, создается ли компонент. Крайне важно, чтобы в этот момент произошла первая проверка выполнения юнит-тестов, поскольку она покажет нам, правильно ли был собран набор тестов.
Услуги по высмеиванию
С этого момента я настоятельно рекомендую вам использовать функцию разделения экрана в вашем редакторе кода, так вы сможете видеть файл typescript с одной стороны, а файл spec — с другой.
Макет инжектируемых зависимостей позволит нам тестировать наш компонент изолированно, не беспокоясь о других зависимостях приложения. В теории, будет создан экземпляр объекта с «фальшивыми» данными, которые будут отражаться при каждом запросе зависимости.
Первое, что следует отметить в коде, это переменные, которые необходимо инициализировать, и зависимости, которые необходимо инжектировать:
import { Component, OnInit, ViewChild, ElementRef, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { ChatOptionsQuery } from 'src/chat/store/chat-options/chat.options.query';
@Component({
selector: 'app-chat-trigger',
templateUrl: './chat-trigger.component.html',
styleUrls: ['./chat-trigger.component.scss'],
})
export class ChatTriggerComponent implements OnInit, OnDestroy {
totalPendingMessages = 0;
maxMessagesCounter = 100
chatTitle: string;
chatMessage: string;
openTooltip: boolean;
ariaLabel:string;
chatTitleSub$: Subscription;
chatMessageSub$: Subscription;
constructor(
private chatOptionsQuery: ChatOptionsQuery,
private appViewStore: AppViewStore,
) { }
onHide(): void {
this.appViewStore.update((state: AppViewState) => ({
...state,
chatOpen: false,
chatMinized: true,
floatChat: true,
}));
}
Служба AppViewStore
используется для вызова метода update
на этом компоненте. В этот момент очень важно быть внимательным, потому что, как мы видим в приведенном ниже коде, при обращении к этой службе метод update
отсутствует.
@Injectable({
providedIn: 'root'
})
@StoreConfig({ name: 'AppView' })
export class AppViewStore extends EntityStore<AppViewState> {
constructor() {
super(initialStateAppView);
}
}
Мы видим, что класс этой службы расширяется от EntityStore
, который содержит метод update
, показанный в коде ниже.
export declare class EntityStore extends Store<S> {
…
update(newState: UpdateStateCallback<S>): any;
Для понимания этого сценария необходимо создать имитатор этих двух классов и добавить метод update
в имитируемый класс со значением MockEntityStore
.
В соответствии с конвенцией Angular, имя моков формируется из имени
Mock + имя сервиса
.
const MockAppViewStore = { };
const MockEntityStore = {
update() {
return true
}
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ ChatTriggerModule],
declarations: [],
providers: [
{ provide: AppViewStore, useValue: MockAppViewStore },
{ provide: EntityStore, useValue: MockEntityStore },
]
})
.compileComponents();
fixture = TestBed.createComponent(ChatTriggerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
Важно, чтобы имя высмеиваемого метода или переменной было идентичным. Но как показано в примере выше, параметр, переданный в методе
update
, не был высмеян, более того, было придумано булево возвращаемое значение. И этого достаточно для проведения тестов.
Создание модульных тестов на практике
Jest использует матчики для эффективного выполнения тестов. Для каждой конкретной ситуации в контексте тестирования существует несколько матчеров. Матчики реализуются путем вызова expect()
. Чтобы вставить пример с более высокой сложностью, прежде всего, нам нужно понять концепцию и то, как реализовать mock-функции.
Макетные функции
- Они позволяют создавать фиктивные функции и модули, имитирующие зависимость.
- С помощью mock можно перехватить вызовы этой функции (и ее параметров) тестируемым кодом.
- Позволяет перехватывать экземпляры функций конструктора при реализации с помощью new.
- Позволяет настроить возвращаемые значения для тестируемого кода.
В других статьях часто можно встретить использование команды jest.fn()
для создания имитационных функций, однако в данном файле используется синтаксис, аналогичный синтаксису Jasmine, поэтому имитационные функции будут создаваться с помощью команды Jest.spyOn(object, nameOfMethod
), соединенной, например, с функцией mockImplementation
, которая позволяет заменить исходную функцию.
Ниже мы приведем несколько примеров матчеров вместе с макетными функциями.
Пример
Мы будем использовать этот код в typescript в качестве основы для этого первого примера, чтобы протестировать хук жизненного цикла ngOnInit()
Angular.
@Input('controls') controls: controls;
@Input("session") session: Session;
public floatChat$: Observable<boolean>;
public chatOpen$: Observable<boolean>;
public joined: boolean;
public joined$: Subscription;
constructor(
public appViewQuery: AppViewQuery,
) {
}
ngOnInit(): void {
this.session = typeof this.session == "string" ? JSON.parse(this.session) : this.session;
this.controls = typeof this.controls == "string" ? JSON.parse(this.controls) : this.controls;
this.floatChat$ = this.appViewQuery.floatChat$;
this.chatOpen$ = this.appViewQuery.chatOpen$;
this.joined$ = this.appViewQuery.joined$.subscribe((data:boolean)=>{
this.joined = data;
});
if (this.controls?.alwaysOpenChat) {
this.onClickChatTrigger();
}
}
Пора изложить то, что было объяснено в начале статьи, этот первоначальный анализ чрезвычайно важен для определения плана действий по созданию тестов на ngOnInit()
. В первых двух строках этого хука у нас есть два троичных if’а, которые используют переменные session
и controls
, имеющие свои собственные интерфейсы. Первый шаг — получить доступ к таким интерфейсам и создать их макет.
export interface Session {
"contactId"?: string,
"sessionId": string,
"rede": string,
"channel": channel,
"nickname": string
}
export enum channel{
"INTERNET_ON" = "INTERNET_ON",
"INTERNET_OFF" = "INTERNET_OFF",
"MOBILE_OFF" = "MOBILE_OFF",
"MOBILE_ON" = "MOBILE_ON"
}
export interface controls {
alwaysOpenChat: boolean,
buttonClose: boolean,
nicknameChat?: string,
nicknameAgent?: string,
iconChat?: string,
}
Мы будем добавлять такие моки глобально (доступ в любой структуре внутри этого spec-файла). Если в будущих тестах вам понадобится изменить какое-то значение, просто сделайте это внутри структуры it
.
Для переменной session
будут добавлены два макета, первый в формате строки, а второй в формате Object. Таким образом, JSON.parse
может быть проверен внутри троичного «if».
describe('ChatComponent', () => {
let component: ChatComponent;
let fixture: ComponentFixture<ChatComponent>;
const mockSessionString: any = '{"contactId": "", "sessionId": "", "rede": "", "channel": "INTERNET_ON", "nickname": ""}';
const mockSessionObject: Session = {
contactId: '',
sessionId: '',
rede: '',
channel: 'INTERNET_ON' as channel,
nickname: ''
};
const mockControls: controls = {
alwaysOpenChat: true,
buttonClose: true,
nicknameChat: '',
nicknameAgent: '',
iconChat: '',
}
...
}
Теперь давайте начнем редактировать спецификацию для этого крючка. Помня, что, как было настроено ранее, мы создали переменную component
, которая ссылается на экземпляр тестируемого класса, поэтому мы присвоим созданные mocks экземпляру класса для этого конкретного теста:
fit('Should test ngOnInit', () => {
component.session = mockSessionString;
component.controls = mockControls;
...
}
Добавив букву «f» к структуре «it», при запуске тестов это будет единственная спецификация, тестируемая в этом наборе.
Продолжая анализ хуков, в следующих трех строках мы присваиваем двум наблюдаемым переменным типа boolean и одной типа «subscription()» значения зависимости AppViewQuery
. На данном этапе нам нужно добавить такую зависимость в *провайдеры тестового пакета и, кроме того, добавить подражаемые переменные.
@Injectable({ providedIn: 'root' })
export class AppViewQuery extends QueryEntity<AppViewState> {
floatChat$ =this.select("floatChat");
chatOpen$ =this.select("chatOpen");
joined$ =this.select("joined");
Когда мы наводим курсор мыши на метод, он показывает нам «тип» того, что возвращается, и для метода select()
это Observable<boolean>
, с этой информацией мы создадим mock, мы будем использовать функцию RxJS of()
:
const MockAppViewQuery = {
floatChat$: of(false),
chatOpen$: of(true),
joined$: of(false)
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [],
declarations: [ChatComponent],
providers: [
{ provide: AppViewQuery, useValue: MockAppViewQuery }
]
})
Если посмотреть на остальную часть хука, у нас есть условие, которое для заданного нами сценария вернет true, потому что this.controls?.alwaysOpenChat
существует. Таким образом, нам нужно будет передразнить метод, который находится внутри условного if()
, для этого примера я использовал mockImplementation(), переписав (случайным образом) метод return в булево true:
fit('Should test ngOnInit', () => {
component.session = mockSessionString;
component.controls = mockControls;
const spyOnClickChatTrigger = jest.spyOn(component, 'onClickChatTrigger').mockImplementation(()=> {
return true;
});
...
}
На данном этапе мы уже подготовили все строки спецификации ngOnInit()
, осталось добавить проверки и команду для выполнения хука:
fit('Should test ngOnInit', () => {
//PREPARAÇÃO
component.session = mockSessionString;
component.controls = mockControls;
const spyOnClickChatTrigger = jest.spyOn(component, 'onClickChatTrigger').mockImplementation(()=> {
return true;
});
//EXECUÇÃO
component.ngOnInit(); //LINHA PARA EXECUTAR O HOOK
//VERIFICAÇÃO
expect(component.session).toEqual(mockSessionObject);
expect(component.controls).toBe(mockControls);
component.floatChat$.subscribe((res: boolean)=>{
expect(res).toBeFalsy();
});
component.floatChat$.subscribe((res: boolean)=>{
expect(res).toBeTruthy();
});
component.chatOpen$.subscribe(()=>{
expect(component.joined).toBeFalsy();
done();
})
expect(spyOnClickChatTrigger).toHaveBeenCalled();
});
Можно сказать, что сборка юнит-тестов всегда следует простой структуре, разделенной на 3 части, определенные в виде комментария в приведенном выше коде. Во время подготовки мы организуем все необходимое для проведения теста; во время выполнения мы фактически выполним тесты; наконец, во время проверки мы определим результат, которого ожидаем.
Для проверки числового равенства используются матчеры toBe и toEqual, где первый дополнительно сравнивает шрифт, а второй — только значения.
1-я проверка: сценарий настроен так, что переменная session
проходит через JSON.parse()
троичного «if». Таким образом, при сравнении с макетом в формате объекта он должен возвращать те же значения.
2-я проверка: сценарий был настроен так, чтобы переменная controls
входила в ложное условие троичного «if» и возвращала тот же объект с той же типизацией.
Функции toBeTruthy и toBeFalsy проверяют, имеет ли переданный результат значение, которое может быть передано как true и false, соответственно, в if.
3-я, 4-я и 5-я проверки: для этих случаев нам нужно подписаться на наблюдаемые переменные, чтобы проверить, соответствует ли высмеиваемый возврат депеши AppViewQuery
тому, что получено переменными floatChat$
, chatOpen$
и joined
. Для типа проверок с асинхронностью мы используем хитрость передачи 1 аргумента в функции «it» под названием done
. После выполнения последней асинхронной проверки мы вызываем функцию done();
, которая позволит сравнить ожидаемые результаты.
6-я проверка: макет переменной controls
заполнен так, что он входит в структуру if()
. Однако в данном случае мы создаем шпиона, который будет возвращать true при каждом вызове метода. Для этого случая мы можем провести различные тесты:
- проверить, является ли возврат переменной spy истинным, используя
toBeTruthy()
; - проверить, был ли вызван метод
onClickChatTrigger()
, используя функциюtoHaveBeenCalled()
; - проверить, был ли метод
onClickChatTrigger()
вызван 1 раз, используя функциюtoHaveBeenCalledTimes(1)
. Мы выбрали вариант 2.
Теперь мы должны запустить набор тестов и проверить, успешно ли прошли тесты.
Выполнение
Базовой командой для запуска набора тестов является:
npm run test
Но когда Jest CLI установлен в проекте, он поддерживает camelCase и пунктирные аргументы, поэтому мы можем объединить 1 или более скриптов с приведенным выше кодом. Пример:
-
--detectOpenHandles
Пытается собрать и распечатать обработчики, которые открыты и мешают Jest выйти из программы. -
--silent
Запрещает тестам печатать сообщения на консоли. -
--coverage
Указывает, что информация о сборе тестов должна собираться и сообщаться на консоли. -
--ci
Jest предполагает выполнение в среде CI (непрерывной интеграции). Изменение поведения при обнаружении нового моментального снимка. Вместо обычного поведения автоматического сохранения нового моментального снимка, тест будет провален и потребует запуска Jest с--updateSnapshot
.
Чтобы запустить тесты только из вышеупомянутого файла, мы используем следующий синтаксис:
npm test -- Chat.component.spec.ts
результат будет:
PASS src/chat/Chat.component.spec.ts (119.938 s)
ChatComponent
√ Should test ngoninit (155 ms)
○ skipped Should test create component
○ skipped Should test ngOnChanges
○ skipped Should test ngAfterViewInit
○ skipped Should test load
○ skipped Should test hasAttribute
Мы понимаем, что наши тесты прошли успешно! Он игнорирует тесты других методов, потому что мы указали «fit» для спецификации ngOnInit()
.
Ссылки
https://jestjs.io/pt-BR/
https://cursos.alura.com.br/forum/topico-jasmine-x-jest-179443
https://www.devmedia.com.br/teste-unitario-com-jest/41234#:~:text=Jest%20%C3%A9%20один%20фреймворк%20из%20сообщества%20JavaScript.
Обзор и благодарности
Я благодарю Жуана Пауло Кастро Лима за идею и поддержку в создании этой статьи, а также моих друзей-рецензентов:
Элвес Гомеш Невеш Сантуш;
Франсис Гомеш Сантуш;
Матеус Винисиус Джеронимо Фалд;
Флавио Такеучи.