برنامه نویسی

تست ادغام با Jest و PostgreSQL

یکی از وظایف حیاتی یک توسعه‌دهنده، نوشتن تست‌های یکپارچه‌سازی برای پایگاه داده است تا اطمینان حاصل کند که پیاده‌سازی طبق انتظار انجام می‌شود. با این حال، اجرای تست های ادغام به طور مستقل و همزمان در برابر یک پایگاه داده مشترک می تواند منجر به تداخل تست ها با یکدیگر و شکست متناوب شود. همه نمونه های کد در TypeScript هستند، اما منطق مشابه برای هر زبان دیگری اعمال می شود.


مشکل زمانی به وجود می آید که مجموعه تست های یکپارچه سازی به طور همزمان در برابر یک نمونه پایگاه داده مشترک اجرا شوند. برای مثال، با توجه به پیاده‌سازی ساده یک مخزن SQL با استفاده از کتابخانه ts-postgres و مجموعه‌های تست Jest ارائه شده در بلوک‌های کد، می‌توانید jest را چند بار اجرا کنید و مشکل همزمانی را تجربه کنید. مشکل از خالی شدن پایگاه داده بین await repository.insert(id); و expect(await repository.findAll()).toEqual([[id]]); عبارات، با توجه به این واقعیت که تست Selects nothing تمام می کند و رکوردهای جدول را درست قبل از عبارت expect حذف می کند.

src/exampleRepository.ts

import { Client, Value } from "ts-postgres";

export class ExampleRepository {
  private readonly _client: Client;

  constructor(client: Client) {
    this._client = client;
  }

  async findAll(): Promise<Array<Value>> {
    return (await this._client.query("SELECT * FROM example")).rows;
  }

  async insert(id: string): Promise<void> {
    await this._client.query("INSERT INTO example (id) VALUES ($1)", [id]);
  }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

src/تست/problematic/insert.test.ts

import { Client } from "ts-postgres";
import { ExampleRepository } from "../../exampleRepository";
import { config } from "dotenv";
import { v4 } from "uuid";
import { rootConnect } from "../postgresUtils";

describe("Insert Test Suite", () => {
  let client: Client;
  let repository: ExampleRepository;

  beforeAll(async () => {
    config();
    client = await rootConnect();
  });

  afterAll(async () => {
    await client.end();
  });

  beforeEach(() => {
    repository = new ExampleRepository(client);
  });

  afterEach(async () => {
    await client.query("DELETE FROM example");
  });

  it("inserts and select", async () => {
    const id = v4();
    await repository.insert(id);
    expect(await repository.findAll()).toEqual([[id]]);
  });
});
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

src/تست/problematic/select.test.ts

import { Client } from "ts-postgres";
import { ExampleRepository } from "../../exampleRepository";
import { config } from "dotenv";
import { rootConnect } from "../postgresUtils";

describe("Select Test Suite", () => {
  let client: Client;
  let repository: ExampleRepository;

  beforeAll(async () => {
    config();
    client = await rootConnect();
  });

  afterAll(async () => {
    await client.end();
  });

  beforeEach(() => {
    repository = new ExampleRepository(client);
  });

  afterEach(async () => {
    await client.query("DELETE FROM example");
  });

  it("selects nothing", async () => {
    expect(await repository.findAll()).toEqual([]);
  });
});
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

src/تست/postgreUtils.ts

export const rootConnect = async () => connectToDatabase(process.env.DB_NAME);

const connectToDatabase = async (database: string) => {
  const client = new Client({
    host: process.env.DB_HOST,
    port: parseInt(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database,
    ssl: SSLMode.Disable,
  });

  await client.connect();
  return client;
};
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید


در حالی که می توانستیم مشخص کنیم --maxWorkers=1 به شوخی، این مدت زمان تست های ما را به شدت افزایش می دهد. یک رویکرد بهتر، فعال کردن تست یکپارچه سازی قابل اعتماد و ایزوله است، با اطمینان از اینکه هر مجموعه آزمایشی در برابر یک نمونه پایگاه داده مجزا و ایزوله اجرا می شود. برای رسیدن به این هدف، می‌توانیم یک پایگاه داده جدید در طول تابع BeforeAll ایجاد کنیم.

با فرض اینکه مجموعه آزمایشی شما در یک پوشه است src/__test__ و اسکریپت های SQL مورد نیاز برای تنظیم جداول پایگاه داده شما موجود است src/sql، راه حل به آسانی استفاده از اتصال ریشه برای ایجاد یک پایگاه داده جدید با نام UUID و سپس استفاده از آن اتصال برای اجرای اسکریپت های مهاجرت SQL است. برای رسیدن به این هدف، می‌توانیم یک کمک‌کننده ساده ایجاد کنیم که به ما امکان می‌دهد تنها یک تابع را در مجموعه‌های آزمایشی خود فراخوانی کنیم. کد اصلاح شده در زیر نشان داده شده است.

src/تست/postgreUtils.ts

import { Client, SSLMode } from "ts-postgres";
import { v4 as uuid } from "uuid";
import { readdirSync, readFileSync } from "fs";

export const connectToTestDatabase = async () => {
  let client = await rootConnect();
  const database = uuid();
  await client.query(`CREATE DATABASE "${database}"`);
  await client.end();
  client = await connectToDatabase(database);
  await runMigrations(client);
  return client;
};

export const rootConnect = async () => connectToDatabase(process.env.DB_NAME);

const connectToDatabase = async (database: string) => {
  const client = new Client({
    host: process.env.DB_HOST,
    port: parseInt(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database,
    ssl: SSLMode.Disable,
  });

  await client.connect();
  return client;
};

const runMigrations = async (client: Client) => {
  const migrationsPath = `${__dirname}/../../sql`;
  for (const filePath of readdirSync(migrationsPath))
    await runMigration(client, `${migrationsPath}/${filePath}`);
};

const runMigration = async (client: Client, filePath) => {
  for (const query of queriesInMigrationFile(filePath))
    await client.query(query);
};

/**
 * :warning: - Fails if a query inserts data containing ";" character
 * @param filePath
 */
const queriesInMigrationFile = (filePath: string) =>
  readFileSync(filePath).toString().split(";");
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

با استفاده از توابع کمکی فوق، می‌توانید اطمینان حاصل کنید که هر مجموعه آزمایشی بر اساس نمونه پایگاه داده خودش اجرا می‌شود، از مسائل همزمانی اجتناب می‌کند و امکان تست یکپارچه‌سازی قابل اعتماد و ایزوله را فراهم می‌کند.


به منظور بهبود عملکرد مهاجرت‌های در حال اجرا، می‌توانیم از یک سرویس گیرنده PostgreSQL استفاده کنیم. در حالی که راه حل قبلی شامل تجزیه سفارشی فایل های SQL ممکن است برای شما خوب کار کند، کلاینت مانند PostgreSQL می تواند کارایی بهتری ارائه دهد و از مهاجرت های شامل TRIGGERS برای مثال پشتیبانی می کند.

برای شروع، به سادگی با پیروی از دستورالعمل های زیر بسته به سیستم عامل خود، یک سرویس گیرنده PostgreSQL را نصب کنید:

  • برای OS X، اجرا کنید brew install libpq && brew link --force libpq
  • برای اوبونتو/دبیان اجرا کنید sudo apt install postgresql-client

نصب را با اجرا تایید کنید psql --version

در مرحله بعد، ما می‌توانیم عملکرد کمکی runMigrations خود را برای اجرای هر فایل مهاجرت با استفاده از پیکربندی مشتری PostgreSQL تغییر دهیم. قطعه کد این مورد در زیر نشان داده شده است:

export const runMigrations: async (client: Client) => {
  for (const filePath of readdirSync(migrationsPath))
    child_process.execSync(
      `PGPASSWORD="${client.config.password}" psql -h ${client.config.host} -p ${client.config.port} -U ${client.config.user} -d ${client.config.database} -a -f ${migrationsPath}/${filePath}`
    );
};
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید


برای پایان دادن به این مقاله، می‌خواهم دو نمونه از کارهای CI با استفاده از سرویس پایگاه داده PostgreSQL را به شما ارائه دهم، بنابراین تست‌های شما را بر روی خط لوله CI/CD خودکار می‌کند.

با gitlab-ci (.gitlab-ci.yml)

stages:
  - test

integration-tests:
  stage: test
  image: node:18.14.0
  services:
    - name: postgres:15.2
      alias: postgres
  variables:
    POSTGRES_DB: jest-psql-example
    POSTGRES_USER: postgres
    POSTGRES_PASSWORD: example
    POSTGRES_HOST_AUTH_METHOD: trust
    DB_HOST: "postgres"
    DB_PORT: "5432"
    DB_USER: "postgres"
    DB_PASSWORD: "example"
    DB_NAME: "my-database"
  before_script:
    - apt-get update && apt-get install -y postgresql-client
    - yarn
    - for f in ./sql/*.sql; do psql -h ${DB_HOST} -U ${DB_USER} -d ${DB_NAME} -a -f $f > /dev/null; done
  script:
    - jest
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

با گردش کار github (.github/worflows/test.yaml)

name: test
on: [ push ]
jobs:
  integration-tests:
    runs-on: ubuntu-latest
    container: node:18.14
    services:
      postgres:
        image: postgres:15.2
        env:
          POSTGRES_DB: jest-psql-example
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: example
          POSTGRES_HOST_AUTH_METHOD: trust
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm install -g yarn
      - run: yarn
      - name: Run migrations
        env:
          DB_HOST: "postgres"
          DB_USER: "postgres"
          PGPASSWORD: "example"
          DB_NAME: "my-database"
        run: |
          apt-get update
          apt-get install --yes postgresql-client
          for f in ./sql/*.sql; do psql -h ${DB_HOST} -U ${DB_USER} -d ${DB_NAME} -a -f $f > /dev/null; done
      - name: Run integration tests
        env:
          DB_HOST: "postgres"
          DB_PORT: "5432"
          DB_USER: "postgres"
          DB_PASSWORD: "example"
          DB_NAME: "jest-psql-example"
        run: jest
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید


تمام نمونه کدها در مخزن Jest-PostgresSQL-Integration-Testing موجود هستند

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا