Как правильно писать и организовывать тесты API Node.js

Как правильно писать и организовывать тесты API Node.js

24 ноября 2022 г.

Тестирование написанного вами кода — важный этап в процессе разработки программного обеспечения. Это гарантирует, что ваше программное обеспечение работает должным образом, и снижает риск появления ошибок и уязвимостей в рабочей среде.

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

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

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

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

Мы начнем с написания модульных тестов для тестирования отдельных компонентов нашего приложения. На следующем этапе мы объединяем эти компоненты и тестируем их с помощью интеграционных тестов. И последнее, но не менее важное: мы настраиваем конвейер CI/CD с помощью действий GitHub и запускаем тесты при каждой отправке.

Обратите внимание, что это не руководство о том, как вообще работает тестирование. Существуют тысячи статей о таких фреймворках, как Jest, Mocha, Supertest и т. д.

Это скорее руководство о том, как подготовить приложение Node и тестовую среду таким образом, чтобы вы могли легко и эффективно писать тесты с подключением к базе данных или без него. Также есть пример репозитория на GitHub. Вы обязательно должны это проверить.

<цитата>

Отказ от ответственности: я знаю, что в архитектуре нет правильного или неправильного. Ниже я предпочитаю один.

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

Одним из преимуществ этой архитектуры является то, что вы можете использовать ее и с другими базами данных, помимо Postgres, например, с MySQL. В этой архитектуре мы не используем ORM. Кроме того, вы можете заменить Jest на Mocha, если вам нужна такая среда тестирования.

Архитектура приложения

Архитектура нашего приложения выглядит примерно так:

node-api
├── api
│   ├── components
│   │   ├── user
|   |   │   ├── tests               // Tests for each component
|   |   |   │   ├── http.spec.ts
|   |   |   │   ├── mock.spec.ts
|   |   │   |   └── repo.spec.ts
|   |   │   ├── controller.ts
|   |   │   ├── dto.ts
|   |   │   ├── repository.ts
|   |   │   └── routes.ts
│   └── server.ts
├── factories                       // Factories to setup tests
|   ├── abs.factory.ts
|   ├── http.factory.ts
|   └── repo.factory.ts
└── app.ts

<цитата>

Примечание. Пример репозитория содержит дополнительный код.

Каждый компонент состоит из следующих четырех файлов:

  • controller.ts: обработчик HTTP
  • dto.ts: объект передачи данных (подробнее)
  • repository.ts: уровень базы данных
  • routes.ts: HTTP-маршрутизация

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

Конфигурация

Мы начнем с создания файла .env.test, содержащего секретные переменные среды для тестирования. Пакет npm Postgres использует их автоматически при установке нового подключения к базе данных. Все, что нам нужно сделать, это убедиться, что они загружаются с помощью dotenv.

NODE_PORT=0
NODE_ENV=test

PGHOST=localhost
PGUSER=root
PGPASSWORD=mypassword
PGDATABASE=nodejs_test
PGPORT=5432

Установка NODE_PORT=0 позволяет Node выбирать первый случайным образом доступный порт, который он находит. Это может быть полезно, если во время тестирования вы запускаете несколько экземпляров HTTP-сервера. Вы также можете установить здесь фиксированное значение, отличное от 0. Используя PGDATABASE, мы указываем имя нашей тестовой базы данных.

Далее настраиваем Jest. Конфиг в jest.config.js выглядит следующим образом:

module.exports = {
  preset: "ts-jest",
  testEnvironment: "node",
  roots: ["src"],
  setupFiles: ["<rootDir>/setup-jest.js"],
}

И setup-jest.js вот так:

require("dotenv").config({
  path: ".env.test",
})

Этот фрагмент обеспечивает загрузку соответствующих переменных среды из предоставленного файла .env перед запуском тестов.

Тестирование с помощью базы данных

Начнем с предположения, что у нас есть тестовая база данных, которую мы можем использовать. Например, это может быть конвейер GitHub Actions CI/CD. Позже я покажу вам, как протестировать ваше приложение без подключения к базе данных.

3 правила

В начале я сказал, что есть несколько важных моментов, которые мы хотим учесть, чтобы значительно облегчить жизнь при тестировании Node API:

  1. Отделить слой базы данных
  2. Отдайте инициализацию подключения к базе данных на аутсорсинг
  3. Отдайте инициализацию HTTP-сервера на аутсорсинг

Что я имею в виду?

Отделите слой базы данных.

У вас должен быть собственный слой, отделенный от вашей бизнес-логики, который отвечает за связь с базой данных. В примере репозитория Git вы можете найти этот слой в файле компонента repository.ts.

Это позволяет нам легко имитировать методы слоя, когда у нас нет базы данных, доступной для тестирования.

Более того, проще заменить вашу базу данных на другую.

export class UserRepository {
  readAll(): Promise<IUser[]> {
    return new Promise((resolve, reject) => {
      client.query<IUser>("SELECT * FROM users", (err, res) => {
        if (err) {
          Logger.error(err.message)
          reject("Failed to fetch users!")
        } else resolve(res.rows)
      })
    })
  }

Аутсорсинг инициализации подключения к базе данных.

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

В большинстве случаев ваше приложение подключается к базе данных в сценарии запуска, таком как index.js. После установления соединения вы запускаете HTTP-сервер.

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

Отдайте инициализацию HTTP-сервера на аутсорсинг

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

Это может выглядеть следующим образом: (позже вы увидите конкретную реализацию)

describe("Component Test", () => {
  beforeEach(() => {
    // Connect to db pool && start Express Server
  });

  afterEach(() => {
    // Release db pool client && stop Express Server
  });

  afterAll(() => {
    // End db pool
  });

В частности, порядок выполнения таков:

  1. Подключиться к пулу баз данных
  2. Запустить начальное значение SQL (создать таблицы)
  3. Запустите экспресс-сервер
  4. Запустите тесты
  5. Выключите сервер Express & выпустить клиент пула БД
  6. Повторите шаги 1–5 для каждого набора тестов и закройте пул в конце.

Тесты

Каждый компонент состоит из двух тестовых файлов:

  • repo.spec.ts
  • http.spec.ts

Оба они используют так называемые TestFactories, которые подготавливают настройку теста. Вы увидите их реализацию в следующей главе.

<цитата>

Примечание. Если вы посмотрите пример репозитория Git, вы увидите, что есть еще два: mock.spec.ts и dto.spec.ts. Первый обсуждается позже. Последнее не рассматривается в этой статье.

repo.spec.ts

Репозиторий — это дополнительный абстрактный уровень, отвечающий за взаимодействие с базой данных, например за чтение и вставку новых данных. Этот слой мы тестируем здесь.

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

Пример на GitHub< /p>

describe("User component (REPO)", () => {
  const factory: RepoTestFactory = new RepoTestFactory()
  const dummyUser = new UserDTO("john@doe.com", "johndoe")

  // Connect to pool
  beforeEach(done => {
    factory.prepare(done)
  })

  // Release pool client
  afterEach(() => {
    factory.closeEach()
  })

  // End pool
  afterAll(done => {
    factory.close(done)
  })

  test("create user", () => {
    const repo: UserRepository = new UserRepository()
    repo.create(dummyUser).then(user => {
      expect(user).to.be.an("object")
      expect(user.id).eq(1)
      expect(user.email).eq(dummyUser.email)
      expect(user.username).eq(dummyUser.username)
    })
  })
})

http.spec.ts

Здесь мы тестируем интеграцию маршрутов, контроллера и репозитория пользовательского компонента. Перед каждым тестовым случаем создается новый клиент пула, как мы делали выше. Кроме того, новый сервер Express запускается с помощью HttpTestFactory. В итоге оба снова закрываются.

Пример на GitHub< /p>

describe("User component (HTTP)", () => {
  const factory: HttpTestFactory = new HttpTestFactory()
  const dummyUser = new UserDTO("john@doe.com", "johndoe")

  // Connect to pool && start Express Server
  beforeEach(done => {
    factory.prepare(done)
  })

  // Release pool client && stop Express Server
  afterEach(done => {
    factory.closeEach(done)
  })

  // End pool
  afterAll(done => {
    factory.close(done)
  })

  test("create user", async () => {
    const postRes = await factory.app
      .post("/users")
      .send(dummyUser)
      .expect(201)
      .expect("Content-Type", /json/)

    const postResUser: IUser = postRes.body
    expect(postResUser).to.be.an("object")
    expect(postResUser.id).eq(1)
    expect(postResUser.email).eq(dummyUser.email)
    expect(postResUser.username).eq(dummyUser.username)
  })
})

Фабрики

Фабрики тестов — это сердце наших тестов. Они отвечают за настройку и подготовку среды для каждого теста. В том числе:

* Отбрасывание & создание всех таблиц БД * Инициализация подключения к БД * Инициализация HTTP-сервера * Закрытие обоих снова

Всего существует четыре фабрики: AbsTestFactory, RepoTestFactory, HttpTestFactory и MockTestFactory. У каждого из них есть свой класс Typescript. Последнее обсуждается в главе "Тестирование без базы данных".

Фабрика AbsTestFactory

Первый AbsTestFactory — это абстрактный базовый класс, который реализуется тремя другими. Среди прочего, он включает в себя метод подключения к пулу баз данных и метод отключения от него.

export abstract class AbsTestFactory implements ITestFactory {
  private poolClient: PoolClient

  private seed = readFileSync(
    join(__dirname, "../../db/scripts/create-tables.sql"),
    {
      encoding: "utf-8",
    }
  )

  abstract prepareEach(cb: (err?: Error) => void): void
  abstract closeEach(cb: (err?: Error) => void): void

  protected connectPool(cb: (err?: Error) => void) {
    pool
      .connect()
      .then(poolClient => {
        this.poolClient = poolClient
        this.poolClient.query(this.seed, cb)
      })
      .catch(cb)
  }

  protected releasePoolClient() {
    this.poolClient.release(true)
  }

  protected endPool(cb: (err?: Error) => void) {
    pool.end(cb)
  }
}

С помощью сценария create-tables.sql фабрика удаляет и воссоздает все таблицы после установления соединения:

DROP TABLE IF EXISTS users;

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(50) UNIQUE NOT NULL,
    username VARCHAR(30) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Фабрика тестов репо

RepoTestFactory используется тестом репозитория каждого компонента (repo.spec.ts), который вы только что видели выше. Все, что он делает, это использует родительский класс AbsTestFactory для подключения к базе данных.

export class RepoTestFactory extends AbsTestFactory {
  prepareEach(cb: (err?: Error) => void) {
    this.connectPool(cb)
  }

  closeEach() {
    this.releasePoolClient()
  }

  closeAll(cb: (err?: Error) => void) {
    this.endPool(cb)
  }
}

Методы prepareEach, closeEach и closeAll вызываются для каждого теста в Jest beforeEach, afterEach и жизненный цикл afterAll.

HttpTestFactory

Последний, HttpTestFactory, используется HTTP-тестом каждого компонента (http.spec.ts). Как и RepoTestFactory, он использует родительский класс для подключения к базе данных. Кроме того, он инициализирует сервер Express.

export class HttpTestFactory extends AbsTestFactory {
  private readonly server: Server = new Server()
  private readonly http: HttpServer = createServer(this.server.app)

  get app() {
    return supertest(this.server.app)
  }

  prepareEach(cb: (err?: Error) => void) {
    this.connectPool(err => {
      if (err) return cb(err)
      this.http.listen(process.env.NODE_PORT, cb)
    })
  }

  closeEach(cb: (err?: Error) => void) {
    this.http.close(err => {
      this.releasePoolClient()
      cb(err)
    })
  }

  closeAll(cb: (err?: Error) => void) {
    this.endPool(cb)
  }
}

Перемотать назад

Давайте вернемся к тестовым файлам repo.spec.ts и http.spec.ts, указанным выше. В обоих случаях мы использовали заводской метод prepareEach перед каждым тестом и его метод afterEach сразу после каждого теста.

Метод closeAll вызывается в самом конце тестового файла. Как вы только что видели, в зависимости от типа фабрики мы устанавливаем соединение с базой данных и при необходимости запускаем HTTP-сервер.

describe("Component Test", () => {
  beforeEach(done => {
    factory.prepareEach(done)
  })

  afterEach(() => {
    factory.closeEach()
  })

  afterAll(done => {
    factory.closeAll(done)
  })
})

Следует иметь в виду одну важную вещь: для каждого тестового примера, в котором используется база данных, фабрика удаляет все таблицы и впоследствии воссоздает их с помощью предоставленного сценария SQL. Таким образом, у нас будет чистая база данных с пустыми таблицами в каждом тестовом примере.

Тестирование без базы данных

До сих пор мы запускали наши тесты с тестовой базой данных, но что, если у нас нет доступа к базе данных? В этом случае нам нужно макетировать нашу реализацию уровня базы данных (repository.ts) , что довольно просто, если отделить его от бизнес-логики, как я рекомендовал в правиле №1.

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

Пример на GitHub< /p>

const dummyUser: IUser = {
  id: 1,
  email: "john@doe.com",
  username: "john",
  created_at: new Date(),
}

// Mock methods
const mockReadAll = jest.fn().mockResolvedValue([dummyUser])

// Mock repository
jest.mock("../repository", () => ({
  UserRepository: jest.fn().mockImplementation(() => ({
    readAll: mockReadAll,
  })),
}))

После имитации уровня базы данных мы можем писать наши тесты как обычно. Используя toHaveBeenCalledTimes(), мы убеждаемся, что наша реализация пользовательского метода была вызвана.

describe("User component (MOCK)", () => {
  const factory: MockTestFactory = new MockTestFactory()

  // Start Express Server
  beforeEach(done => {
    factory.prepareEach(done)
  })

  // Stop Express Server
  afterEach(done => {
    factory.closeEach(done)
  })

  test("get users", async () => {
    const getRes = await factory.app
      .get("/users")
      .expect(200)
      .expect("Content-Type", /json/)

    const getResUsers: IUser[] = getRes.body
    cExpect(getResUsers).to.be.an("array")
    cExpect(getResUsers.length).eq(1)

    const getResUser = getResUsers[0]
    cExpect(getResUser).to.be.an("object")
    cExpect(getResUser.id).eq(dummyUser.id)
    cExpect(getResUser.email).eq(dummyUser.email)
    cExpect(getResUser.username).eq(dummyUser.username)

    expect(mockReadAll).toHaveBeenCalledTimes(1)
  })
})

<цитата>

Примечание. cExpect — это именованный импорт из пакета "chai".

MockTestFactory

Как и в других тестовых файлах, здесь мы также используем тестовую фабрику. Все, что делает MockTestFactory, — это запускает новый экземпляр Express HTTP. Он не устанавливает соединение с базой данных, поскольку мы имитируем уровень базы данных.

export class MockTestFactory extends AbsTestFactory {
  private readonly server: Server = new Server()
  private readonly http: HttpServer = createServer(this.server.app)

  get app() {
    return supertest(this.server.app)
  }

  prepareEach(cb: (err?: Error) => void) {
    this.http.listen(process.env.NODE_PORT, cb)
  }

  closeEach(cb: (err?: Error) => void) {
    this.http.close(cb)
  }
}

Один из недостатков этого подхода заключается в том, что слой (repository.ts) вообще не тестируется, потому что мы перезаписываем его. Тем не менее, мы все еще можем протестировать остальную часть нашего приложения, например, бизнес-логику. Отлично!

Бег

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

{
  "test:db": "jest --testPathIgnorePatterns mock.spec.ts",
  "test:mock": "jest --testPathIgnorePatterns "(repo|http).spec.ts""
}

Действия GitHub

Последний шаг — создать конвейер CI/CD с помощью действий GitHub, которые запускают наши тесты. Соответствующий файл yaml доступен здесь. Также на GitHub опубликовано очень хорошее руководство.

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

При запуске конвейера с тестовой базой данных нам нужно убедиться, что мы установили для него правильные переменные среды. Здесь вы можете найти тестовый запуск.

Последние слова

Мой последний совет: взгляните на репозиторий примеров на GitHub и внимательно его прочитайте. еще несколько тестов и фрагментов кода, которые я не рассмотрел в этой статье. Кроме того, проверьте ссылки ниже. Удачного кодирования!

Дополнительные ресурсы


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE