Как провести модульное тестирование HTTP-сервиса в Angular

Теперь мы понимаем важность изолированного тестирования, и я хочу сместить наше внимание на тестирование службы HTTP. К тому времени, когда мы завершим это дополнение к серии, вы не только поймете, как писать ценные тесты, но и поймете, что тестировать - что, как мне кажется, многие новички в модульном тестировании. изо всех сил пытаются осмыслить.

Если вы не читали части первая и вторая, я рекомендую вам вернуться к этой статье после ознакомления с основополагающими концепциями, изложенными в этих сообщениях.

Понимание настройки

Для целей этой статьи я создал новое приложение Angular и загрузил в проект json-сервер, чтобы мы могли делать запросы API и дополнять наш процесс обучения. По умолчанию этот API работает на localhost:3000.

Если вы хотите продолжить, не стесняйтесь клонировать это репо, прежде чем продолжить! Я создал starting ветку, в которой есть все, что вам нужно!

Изменение karma.config с помощью ChromeHeadless

Когда вы запускаете ng test в новом проекте Angular, отчет Karma откроется на новой вкладке Chrome. Я предпочитаю, чтобы результаты моих тестов отображались в терминале. Чтобы внести это изменение, измените свойство browsers в вашем karma.config.js файле.

module.exports = function(config) {
    config.set({
    ...
    browsers: ['ChomeHeadless'],
    });
}

HTTP-сервис Angular, который мы будем тестировать

Я создал очень упрощенную службу HTTP со всеми операциями CRUD. Взгляните ниже.

@Injectable({
  providedIn: 'root',
})
export class BooksService {
  url = 'localhost:3000/';
httpOptions = {
    headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
  };
constructor(private http: HttpClient) {}
getAllBooks(): Observable<Book[]> {
    return this.http
      .get<Book[]>(`${this.url}/books`)
      .pipe(catchError(this.handleError<Book[]>('getAllBooks', [])));
  }
getBookById(id: number): Observable<Book> {
    return this.http
      .get<Book>(`${this.url}/books/${id}`)
      .pipe(catchError(this.handleError<Book>(`getBookById id=${id}`)));
  }
updateBook(book: Book): Observable<any> {
    return this.http
      .put(`${this.url}/books`, book, this.httpOptions)
      .pipe(catchError(this.handleError<any>(`updateBook`)));
  }
addBook(book: Book): Observable<Book> {
    return this.http
      .post<Book>(`${this.url}/books`, book, this.httpOptions)
      .pipe(catchError(this.handleError<Book>(`addBook`)));
  }
deleteBook(book: Book): Observable<Book> {
    return this.http
      .delete<Book>(`${this.url}/books/${book.id}`, this.httpOptions)
      .pipe(catchError(this.handleError<Book>(`deleteBook`)));
  }
private handleError<T>(operation = 'operation', result?: T) {
    return (error: any): Observable<T> => {
      console.error(`${operation} failed: ${error.message}`);
return of(result as T);
    };
  }
}

Если вам не нравится какая-либо из этих функций и то, что они делают, или различные задействованные операторы, прочтите официальную документацию Angular о создании HTTP-сервисов.

Я определил URL здесь, в службе, но в идеале он должен быть получен из переменной среды, определенной в вашем проекте.

Что мне нужно для модульного тестирования?

Теперь, когда эта базовая услуга задействована, самое время обратиться к слону в комнате. Что вам следует протестировать в этом классе? Всего существует пять функций, каждая из которых выполняет вызов API к нашему бэкэнду json-сервера.

Все функции, которые мы создаем, будь то компонент или служба, должны иметь вспомогательные тестовые примеры.

Чтобы помочь определить, что тестировать, давайте вкратце обратим наше внимание на простую метафору из моей предыдущей статьи под названием Машина для гамбола: как быстро идентифицировать примеры модульного тестирования.

Гамбол-машина

Как работает автомат для жевания жевательной резинки? Есть три основных события:

  1. Положите четверть в машину
  2. Поверните ручку
  3. Раскатывается жевательная резинка

Думайте о функциях как о машине для жевания жевательной резинки и выполните три шага:

  1. Поместите четверть в машину (при необходимости передайте аргументы функции)
  2. Повернуть ручку (выполнить тестируемый код - саму функцию)
  3. Раскатывается жевательная резинка (проверьте поведение - функция возвращает ожидаемые данные)

Я считаю полезным сканировать функцию и записывать различные логические ветви и возможные значения, которые могут быть возвращены. Эти примечания становятся схемой написания модульных тестов для этой функции.

Определение того, что тестировать в Angular HTTP-сервисе

Уделите секунду и осмотрите вышеупомянутую Службу. Просмотрите функции и определите ввод и вывод. Есть ли еще что-нибудь, что нам было бы полезно проверить? Составьте схему тестирования и продолжайте читать.

Выполнено?

Вот что я придумал:

  • Убедитесь, что функции возвращают соответствующие данные (массив Книг или отдельную Книгу)
  • Убедитесь, что ожидаемая конечная точка API была вызвана с помощью соответствующего метода запроса.
  • В случае возникновения ошибки убедитесь, что функция handleError была вызвана с соответствующим аргументом (ами). ПРИМЕЧАНИЕ. В этой статье я не буду заострять внимание на этом тестовом примере.

Добавление HttpClientTestingModule в наш тестовый файл Angular Unit

Выполнение тестов на этом этапе вызывает ошибку. Вы можете догадаться, почему?

Chrome Headless 92.0.4515.159 (Mac OS 10.15.7) BooksService should be created FAILED
        NullInjectorError: R3InjectorError(DynamicTestModule)[BooksService -> HttpClient -> HttpClient]: 
          NullInjectorError: No provider for HttpClient!
        error properties: Object({ ngTempTokenPath: null, ngTokenPath: [ 'BooksService', 'HttpClient', 'HttpClient' ] })
...

Сообщение об ошибке фактически дает нам подсказку. Мы не тестируем эту Службу изолированно - она ​​имеет встроенную зависимость: HTTP-клиент. Чтобы тест по умолчанию прошел в Сервисе, нам нужно ввести HttpClientTestingModule - модуль, который предоставляет все инструменты, необходимые для правильного тестирования Angular HTTP Services.

import { HttpClientTestingModule } from '@angular/common/http/testing';
...
beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule]
    });
    service = TestBed.inject(BooksService);
  });

Теперь тест должен пройти. Большой!

Можно тестировать службы HTTP без использования HTTPClientTestingModule, имитируя различные зависимости, но для простоты в этом руководстве будут продемонстрированы только решения с модулем, предоставляемым Angular.

Шаблон модульного тестирования: упорядочить-действовать-утверждать

При написании модульных тестов мне нравится следовать шаблону Arrange-Act-Assert (3 A), чтобы помочь структурировать свои тестовые примеры.

  1. Расставить - настроить тест-кейс. Требуется ли особая подготовка к тесту? Используйте этот шаг, чтобы получить тестируемый код (сервисная функция) в том месте, где мы можем делать наши утверждения. Бывают моменты, когда нечего аранжировать. Ничего страшного - переходите к следующему шагу.
  2. Act - выполнить тестируемый код. Чтобы определить ожидаемое поведение программного обеспечения, нам нужно запустить тестируемый код. Передайте все необходимые аргументы тестируемому коду, чтобы добиться ожидаемого поведения.
  3. Утверждать - проверять ожидаемые результаты. Это шаг, который фактически определяет, прошел ли ваш тест или нет.

Написание модульного теста Angular для функции getAllBooks

Давайте сосредоточимся на первом фрагменте кода службы HTTP - функции getAllBooks. Он не принимает никаких аргументов функции и должен возвращать массив книг.

Имея это в виду, давайте создадим новый тест и добавим следующую логику тестирования:

import {
  HttpClientTestingModule,
  HttpTestingController,
} from '@angular/common/http/testing';
import { mockBookArray } from 'src/mocks/mockBooks';
describe('BooksService', () => {
    let service: BooksService;
  let httpController: HttpTestingController;
let url = 'localhost:3000/';
beforeEach(() => {
        TestBed.configureTestingModule({
          imports: [HttpClientTestingModule],
        });
        service = TestBed.inject(BooksService);
        httpController = TestBed.inject(HttpTestingController);
      });
it('should call getAllBooks and return an array of Books', () => {
           // 1
          service.getAllBooks().subscribe((res) => {
           //2
          expect(res).toEqual(mockBookArray);
        });
           //3
        const req = httpController.expectOne({
          method: 'GET',
          url: `${url}/books`,
        });
           //4
        req.flush(mockBookArray);
      });
}

Это может показаться большим количеством и сбивать с толку, поэтому позвольте мне разобрать это.

  1. Я вызываю тестируемый код - функция getAllBooks. Это часть шага Act в шаблоне Arrange-Act-Assert.
  2. Я убеждаюсь, что данные, возвращаемые функцией, представляют собой массив Книг, который я высмеял и перенес в этот тестовый файл. Это соответствует шагу Assert в шаблоне Arrange-Act-Assert. Вы можете подумать, что это выглядит забавно; зачем нам подписываться на функцию getAllBooks? Функция возвращает Observable, поэтому единственный способ проверить возвращаемые данные - это подписаться на Observable и сделать утверждение внутри.
  3. Мы создали и используем HttpTestingController по нескольким причинам, но здесь мы используем его, чтобы указать URL-адрес, который, как мы ожидаем, сработает функция Service, а также метод запроса, который будет использоваться.
  4. Мы также используем HttpTestingController для сброса (отправки) данных через поток. На первый взгляд кажется, что это идет вразрез с обычным шаблоном тестирования, когда вы указываете возвращаемые данные перед оператором утверждения. Однако, поскольку мы должны подписаться на функцию getAllBooks, мы сбрасываем данные после того, как мы прослушиваем этот Observable для выдачи значения.

Чтобы быть еще более ясным, когда выполняется инструкция flush, она отправляет mockBookArray данные через поток, блок подписки разрешается, и затем выполняется наше утверждение.

На этом этапе, если вы запустите тест, вы должны получить отметку о прохождении.

Если вам нужен доступ к фиктивным данным, которые я использую в этих примерах, посетите мой репозиторий GitHub в ветке completed_test.

Написание модульного теста для функции getBookById

Эта функция аналогична первой. Можете ли вы придумать критерии тестирования?

Вот как я тестирую эту функцию:

import { mockBook1, mockBookArray } from 'src/mocks/mockBooks';
...
it('should call getBookById and return the appropriate Book', () => {
     // Arrange
    const id = '1';
     // Act
    service.getBookById(id).subscribe((data) => {
     // Assert
      expect(data).toEqual(mockBook1);
    });
    const req = httpController.expectOne({
      method: 'GET',
      url: `${url}/books/${id}`,
    });
    req.flush(mockBook1);
});

Этот тест позволяет вам увидеть немного больше паттерна Arrange-Act-Assert. Из-за природы тестируемого кода мы знаем, что функция требует передачи значения идентификатора. Мы контролируем это со стороны теста, объявляя переменную id, устанавливая значение '1' и передавая его функции getBookById.

Все остальное знакомо - мы по-прежнему проверяем, является ли метод запроса GET и попадает ли соответствующий URL-адрес. Мы также отправляем фиктивную книгу с помощью метода flush, так что наше утверждение начинается внутри блока подписки.

Написание модульного теста для функции updateBook

Теперь давайте посмотрим на функцию updateBook. Здесь применяются те же шаблоны, но другой метод запроса. Не позволяйте этому вас напугать! Обратите внимание, какие аргументы требуются функции и каков ожидаемый результат, а затем напишите тест.

it('should call updateBook and return the updated book from the API', () => {
    const updatedBook: Book = {
      id: '1',
      title: 'New title',
      author: 'Author 1',
    };
    service.updateBook(mockBook1).subscribe((data) => {
      expect(data).toEqual(updatedBook);
    });
    const req = httpController.expectOne({
      method: 'PUT',
      url: `${url}/books`,
    });
    req.flush(updatedBook);
});

Заключение

Как только вы усвоите шаблон, тестирование HTTP-сервисов в Angular не составит труда.

Попробуйте протестировать остальные функции в классе Service. Ты можешь сделать это?

Не стесняйтесь проверить ветку completed_tests моего репозитория GitHub и использовать ее как справочник, если вы застряли!

Спасибо за прочтение! Если вам понравилась эта статья и вы нашли ее полезной, подумайте о прочтении других моих статей и подписке на мою рассылку новостей ниже!

Первоначально опубликовано на https://braydoncoyer.dev/blog.