开玩笑:在运行所有测试之前如何跨文件共享数据库连接?

Hir*_*oki 9 jestjs

这是一个例子。

有3个测试文件

test
 - customer.test.ts
 - user.test.ts
 - staff.test.ts
Run Code Online (Sandbox Code Playgroud)

它们每个都需要一个数据库连接(无论您使用什么数据库和 ORM),因此您将在所有测试文件中看到以下行。

describe('staff.test.ts', () => {
  let testDb;

  beforeAll(async () => {
    testDb = await getTestDb(); // This returns a DB connection
  });

  afterEach(async () => {
    await testDb.synchronize(true);
  });

  afterAll(async () => {
    await testDb.close();
  });

  // tests are here
});
Run Code Online (Sandbox Code Playgroud)

在所有的文件中,

  1. testDb总是用相同的函数实例化 ( getTestDb())
  2. beforeAllafterEachafterAll被复制/粘贴。

我怎么能够...

  1. 在所有文件之间共享实例化的数据库连接,
  2. 在运行所有测试之前调用beforeAllandafterAll
  3. 调用afterEach每个测试用例?

似乎globalSetup允许我导出“在所有测试套件之前触发一次的异步函数”。但是,我不仅必须运行beforeAll,而且还必须共享实例化的数据库连接。

可以实现吗?任何建议将被认真考虑!

小智 0

我最近偶然发现了同样的问题,并制定了自己的框架式解决方案

Jest 不在全局运行器和套件之间共享对象实例(通过全局对象对可序列化数据的支持有限),因此我们将使用数据库管理服务器在它们之间进行通信(jest-puppeteer使用共享的 ws 端点)。

Jest 配置看起来像这样:

export default {
  ...,
  globalSetup: "globalSetup.ts",
  globalTeardown: "globalTeardown.ts",
  setupFilesAfterEnv: ["database.setup.ts"],
};
Run Code Online (Sandbox Code Playgroud)

globalSetup.ts

import type { Config } from "@jest/types";

import { handlers, startServer } from "./db-manager";

declare global {
  var controlHandle: {
    kill: () => Promise<unknown>;
    handlers: typeof handlers;
  };
  var serverConfig: {
    port: number;
    hostname: string;
  };
}

export default async (
  config: Config.GlobalConfig,
  projectConfig: Config.ProjectConfig,
) => {
  const serverConfig: (typeof globalThis)["serverConfig"] = {
    // Pick a port to communicate, I look for a free on every run
    port: -1,
    hostname: "localhost",
  };
  // This will be serialized and passed to setupFilesAfterEnv files as well as tests themselves
  projectConfig.globals.serverConfig = routerConfig;
  // This spins up a DB manager server and resolves whenever it's ready
  const serverHandle = await startServer(serverConfig.port, serverConfig.hostname);
  globalThis.controlHandle = {
    kill: serverHandle.kill,
    handlers,
  };
  // My config data has amount of workers Jest is running so I can limit my DB pool to that value
  await handlers.setup(config.maxWorkers);
};
Run Code Online (Sandbox Code Playgroud)

globalTeardown.ts

export default async () => {
  // Shutting down still running queries
  await globalThis.controlHandle.handlers.teardown();
  // Killing the server itself
  await globalThis.controlHandle.kill();
};
Run Code Online (Sandbox Code Playgroud)

database.setup.ts

import { getDatabase } from "app/database";

const { port, hostname } = globalThis.routerConfig;
// Some kind of http client, implementation omitted
const client = createClient(hostname, port)

declare global {
  var testContext:
    | {
        database: MyDatabaseBuilder;
        databaseName: string;
      }
    | undefined;
}

beforeAll(async () => {
  // Wait until server resolves with a free database
  const { databaseName, connectionData } = await client.lockDatabase();
  global.testContext = {
    // Implementation ommited, any SQL builder you prefer
    database: getDatabase(connectionData, databaseName),
    databaseName,
  };
});

afterEach(async () => {
  const { databaseName } = global.testContext!;
  // Clear up a database before next `test` in a suite
  await client.truncateDatabase(databaseName);
});

afterAll(async () => {
  const { databaseName, database } = global.testContext!;
  // Don't forget to close a Pool / do cleanup on database object
  await database.cleanup();
  // Report to a DB manager server that this database may be released
  await client.releaseDatabase(databaseName);
});
Run Code Online (Sandbox Code Playgroud)

这就是您在测试中使用它的方式: write.test.ts

test("write to DB", () => {
  const { database } = global.testContext!;
  // This .sql(..) syntax is a stub
  await database.sql("INSERT INTO products (id, name, price) VALUES (1, "Soap", 100)");
  expect(await database.sql("SELECT * FROM products")).toHaveLength(1);
  console.log("Current database:", await database.sql("SELECT current_database()"))
});
Run Code Online (Sandbox Code Playgroud)

read.test.ts

test("read from DB", () => {
  const { database } = global.testContext!;
  // This is a different database
  expect(await database.sql("SELECT * FROM products")).toHaveLength(0);
  console.log("Current database:", await database.sql("SELECT current_database()"))
});
Run Code Online (Sandbox Code Playgroud)

如果您正确地实现了数据库管理器,那么您将在两个套件上拥有不同的数据库,或者在套件之间拥有相同的数据库但被截断。

我使用带有 Postgres 的 docker 镜像作为数据库管理器,它的完成方式得到了极大的简化: db-manager.ts

import { GenericContainer, StartedTestContainer } from "testcontainers";
import { EventEmitter } from "events";

type ManagerInstance = {
  container: StartedTestContainer;
  maxDatabases: number;
  connectionData: {
    host: string;
    username: string;
    password: string;
    port: number
  }
}
let instance: ManagerInstance | undefined;
type DatabaseInstance = {
    name: string;
    locked: boolean;
}
let databases: DatabaseInstance[] = [];
const databaseEventEmitter = new EventEmitter();

const createDatabase = async () => {
  if (databases.length >= maxDatabases) {
    return;
  }
  // You might want to implement a queue for a template database
  // As it is not possible to clone several DBs from one template concurrently
  const name = /* pick a random name */;
  const database = getDatabase(connectionData, templateDatabaseName);
  await database.sql(`CREATE DATABASE "${name}" TEMPLATE "${templateDatabaseName}"`);
  await database.cleanup();
  databases.push({ name, locked: false });
  databaseEventEmitter.emit("release", name);
}

export const handlers = {
  setup: async (maxDatabases: number) => {
    const postgresData = {
      host: "localhost",
      port: -1
      user: "postgres",
      password: "postgres-password",
      templateDatabase: "template-db",
    }
    const container = new GenericContainer("postgres")
      .withExposedPorts(postgresData.port)
      .withEnv("POSTGRES_USER", postgresData.user)
      .withEnv("POSTGRES_PASSWORD", postgresData.password)
      .withEnv("POSTGRES_DB", postgresData.templateDatabase);
    const runningContainer = await container.start();
    const connectionData = {
      host: postgresData.host,
      username: postgresData.user,
      password: postgresData.password,
      port: runningContainer.getMappedPort(postgresData.port),
    };

    const database = getDatabase(connectionData, postgresData.templateDatabase);
    // Probably, you want your databases be at the same state schema-wise before test
    // Implementation omitted
    await migrate(database);
    await database.cleanup();

    instance = {
      container: runningContainer,
      connectionData,
      maxDatabases,
    };
  },
  teardown: async () => {
    await instance.container.stop({ timeout: 10000 });
  },
  lockDatabase: async () => {
    // First, we try to find a database not locked by other threads
    const firstUnlockedDatabase = databases.find((database) => !database.locked);
    if (firstUnlockedDatabase) {
      firstUnlockedDatabase.locked = true;
      return {
        databaseName: firstUnlockedDatabase.name,
        connectionData: instance.connectionData,
      };
    }
    // If we didn't get to the limit of DBs, we create a new one
    if (databases.length < instance.maxDatabases) {
      // We don't wait until it's got created, just subscribe to the event
      void createDatabase();
    }
    // Otherwise, we wait until a database will get released
    return new Promise((resolve) => {
      databaseEventEmitter.once("release", (databaseName) => {
        const database = databases.find((database) => database.name === databaseName);
        database.locked = true;
        resolve({
          databaseName: name,
          connectionData: instance.connectionData,
        })
      })
    });
  },
  truncateDatabase: (databaseName: string) => {
    const database = getDatabase(instance.connectionData, databaseName);
    // We could just drop a DB and recreate it from a template, but it takes way too long
    await database.sql("TRUNCATE products RESTART IDENTITY");
    // If you get pg-pool errors on Jest exit, probably this cleanup didn't finish before process stopped
    // You may want to implement a cleanup manager that will track all database instances needed to close connections before exit
    // And run global cleanup on teardown handler
    await database.cleanup();
  },
  releaseDatabase: (databaseName: string) => {
    const database = databases.find((database) => database.name === databaseName);
    database.locked = false;
    databaseEventEmitter.emit("release", databaseName);
  },
};

export const startServer = async (port: number) => {
    // Implement a server using handlers from above and listening on given port
    // Resolves when starts to listen
}
Run Code Online (Sandbox Code Playgroud)

完整的工作版本可以在我在实现它的过程中制作的PR中找到。