Bladeren bron

feat(testing): Modularize DB support for e2e tests

Relates to #207. This change decouples the TestServer from the underlying database, allowing custom TestDbInitializers to be created for other TypeORM-supported DBs.

BREAKING CHANGE: The `@vendure/testing` package now requires you to explicitly register initializers for the databases you with to test against. This change enables e2e tests to be run against any database supported by TypeORM. The `dataDir` option has been removed from the call to the `TestServer.init()` method, as it is specific to the SqljsInitializer:

before:
```TypeScript
import { createTestEnvironment, testConfig } from '@vendure/testing';

describe('my e2e test suite', () => {
    const { server, adminClient } = createTestEnvironment(testConfig);

    beforeAll(() => {
        await server.init({
            dataDir: path.join(__dirname, '__data__'),
            initialData,
            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
            customerCount: 1,
        });
    });

    //...
});
```

after:
```TypeScript
import { createTestEnvironment, registerInitializer, SqljsInitializer, testConfig } from '@vendure/testing';

registerInitializer('sqljs', new SqljsInitializer(path.join(__dirname, '__data__')));

describe('my e2e test suite', () => {
    const { server, adminClient } = createTestEnvironment(testConfig);

    beforeAll(() => {
        await server.init({
            initialData,
            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
            customerCount: 1,
        });
    });

    //...
});
```
Michael Bromley 6 jaren geleden
bovenliggende
commit
f8060b5544

+ 24 - 3
docs/content/docs/developer-guide/testing.md

@@ -23,6 +23,29 @@ The `@vendure/testing` package gives you some simple but powerful tooling for cr
 
 Please see the [Jest documentation](https://jestjs.io/docs/en/getting-started) on how to get set up. The remainder of this article will assume a working Jest setup configured to work with TypeScript.
 
+### Register database-specific initializers
+
+The `@vendure/testing` package uses "initializers" to create the test databases and populate them with initial data. We ship with initializers for `sqljs`, `postgres` and `mysql`. Custom initializers can be created to support running e2e tests against other databases supported by TypeORM. See the [`TestDbInitializer` docs]({{< relref "test-db-initializer" >}}) for more details.
+
+```TypeScript
+import {
+    MysqlInitializer,
+    PostgresInitializer,
+    SqljsInitializer,
+    registerInitializer,
+} from '@vendure/testing';
+
+const sqliteDataDir = path.join(__dirname, '__data__');
+
+registerInitializer('sqljs', new SqljsInitializer(sqliteDataDir));
+registerInitializer('postgres', new PostgresInitializer());
+registerInitializer('mysql', new MysqlInitializer());
+```
+
+{{% alert "primary" %}}
+Note re. the `sqliteDataDir`: The first time this test suite is run with the `SqljsInitializer`, the populated data will be saved into an SQLite file, stored in the directory specified by this constructor arg. On subsequent runs of the test suite, the data-population step will be skipped and the initial data directly loaded from the SQLite file. This method of caching significantly speeds up the e2e test runs. All the .sqlite files created in the `sqliteDataDir` can safely be deleted at any time.
+{{% /alert %}}
+
 ### Create a test environment
 
 The `@vendure/testing` package exports a [`createTestEnvironment` function]({{< relref "create-test-environment" >}}) which is used to set up a Vendure server and GraphQL clients to interact with both the Shop and Admin APIs:
@@ -60,7 +83,6 @@ beforeAll(async () => {
     await server.init({
         productsCsvPath: path.join(__dirname, 'fixtures/e2e-products.csv'),
         initialData: myInitialData,
-        dataDir: path.join(__dirname, '__data__'),
         customerCount: 2,
     });
     await adminClient.asSuperAdmin();
@@ -74,8 +96,7 @@ afterAll(async () => {
 An explanation of the options:
 
 * `productsCsvPath` This is a path to a CSV file containing product data. The format is as-yet undocumented and may be subject to change, but you can see [an example used in the Vendure e2e tests](https://github.com/vendure-ecommerce/vendure/blob/master/packages/core/e2e/fixtures/e2e-products-full.csv) to get an idea of how it works. To start with you can just copy this file directly and use it as-is.
-* `initialData` This is an object which defines how other non-product data (Collections, ShippingMethods, Countries etc.) is populated. Again, the best idea is to [copy this example from the Vendure e2e tests](https://github.com/vendure-ecommerce/vendure/blob/master/packages/core/e2e/fixtures/e2e-initial-data.ts)
-* `dataDir` The first time this test suite is run, the data populated by the options above will be saved into an SQLite file, stored in the directory specified by this options. On subsequent runs of the test suite, the data-population step will be skipped and the data directly loaded from the SQLite file. This method of caching significantly speeds up the e2e test runs. All the .sqlite files created in the `dataDir` can safely be deleted at any time.
+* `initialData` This is an object which defines how other non-product data (Collections, ShippingMethods, Countries etc.) is populated. Again, the best idea is to [copy this example from the Vendure e2e tests](https://github.com/vendure-ecommerce/vendure/blob/master/e2e-common/e2e-initial-data.ts)
 * `customerCount` Specifies the number of fake Customers to create. Defaults to 10 if not specified.
 
 ### Write your tests

+ 5 - 0
packages/testing/src/index.ts

@@ -4,3 +4,8 @@ export * from './config/test-config';
 export * from './create-test-environment';
 export * from './data-population/clear-all-tables';
 export * from './data-population/populate-customers';
+export * from './initializers/initializers';
+export * from './initializers/test-db-initializer';
+export * from './initializers/mysql-initializer';
+export * from './initializers/postgres-initializer';
+export * from './initializers/sqljs-initializer';

+ 26 - 0
packages/testing/src/initializers/initializers.ts

@@ -0,0 +1,26 @@
+import { ConnectionOptions } from 'typeorm';
+
+import { TestDbInitializer } from './test-db-initializer';
+
+export type InitializerRegistry = { [type in ConnectionOptions['type']]?: TestDbInitializer<any> };
+
+const initializerRegistry: InitializerRegistry = {};
+
+/**
+ * @description
+ * Registers a {@link TestDbInitializer} for the given database type. Should be called before invoking
+ * {@link createTestEnvironment}.
+ *
+ * @docsCategory testing
+ */
+export function registerInitializer(type: ConnectionOptions['type'], initializer: TestDbInitializer<any>) {
+    initializerRegistry[type] = initializer;
+}
+
+export function getInitializerFor(type: ConnectionOptions['type']): TestDbInitializer<any> {
+    const initializer = initializerRegistry[type];
+    if (!initializer) {
+        throw new Error(`No initializer has been registered for the database type "${type}"`);
+    }
+    return initializer;
+}

+ 51 - 0
packages/testing/src/initializers/mysql-initializer.ts

@@ -0,0 +1,51 @@
+import path from 'path';
+import { ConnectionOptions } from 'typeorm';
+import { MysqlConnectionOptions } from 'typeorm/driver/mysql/MysqlConnectionOptions';
+import { promisify } from 'util';
+
+import { TestDbInitializer } from './test-db-initializer';
+
+export class MysqlInitializer implements TestDbInitializer<MysqlConnectionOptions> {
+    private conn: import('mysql').Connection;
+
+    async init(
+        testFileName: string,
+        connectionOptions: MysqlConnectionOptions,
+    ): Promise<MysqlConnectionOptions> {
+        const dbName = this.getDbNameFromFilename(testFileName);
+        this.conn = await this.getMysqlConnection(connectionOptions);
+        (connectionOptions as any).database = dbName;
+        (connectionOptions as any).synchronize = true;
+        const query = promisify(this.conn.query).bind(this.conn);
+        await query(`DROP DATABASE IF EXISTS ${dbName}`);
+        await query(`CREATE DATABASE IF NOT EXISTS ${dbName}`);
+        return connectionOptions;
+    }
+
+    async populate(populateFn: () => Promise<void>): Promise<void> {
+        await populateFn();
+    }
+
+    async destroy() {
+        await promisify(this.conn.end).bind(this.conn)();
+    }
+
+    private async getMysqlConnection(
+        connectionOptions: MysqlConnectionOptions,
+    ): Promise<import('mysql').Connection> {
+        const { createConnection } = await import('mysql');
+        const conn = createConnection({
+            host: connectionOptions.host,
+            port: connectionOptions.port,
+            user: connectionOptions.username,
+            password: connectionOptions.password,
+        });
+        const connect = promisify(conn.connect).bind(conn);
+        await connect();
+        return conn;
+    }
+
+    private getDbNameFromFilename(filename: string): string {
+        return 'e2e_' + path.basename(filename).replace(/[^a-z0-9_]/gi, '_');
+    }
+}

+ 48 - 0
packages/testing/src/initializers/postgres-initializer.ts

@@ -0,0 +1,48 @@
+import path from 'path';
+import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions';
+
+import { TestDbInitializer } from './test-db-initializer';
+
+export class PostgresInitializer implements TestDbInitializer<PostgresConnectionOptions> {
+    private client: import('pg').Client;
+
+    async init(
+        testFileName: string,
+        connectionOptions: PostgresConnectionOptions,
+    ): Promise<PostgresConnectionOptions> {
+        const dbName = this.getDbNameFromFilename(testFileName);
+        (connectionOptions as any).database = dbName;
+        (connectionOptions as any).synchronize = true;
+        this.client = await this.getPostgresConnection(connectionOptions);
+        await this.client.query(`DROP DATABASE IF EXISTS ${dbName}`);
+        await this.client.query(`CREATE DATABASE ${dbName}`);
+        return connectionOptions;
+    }
+
+    async populate(populateFn: () => Promise<void>): Promise<void> {
+        await populateFn();
+    }
+
+    destroy(): void | Promise<void> {
+        return this.client.end();
+    }
+
+    private async getPostgresConnection(
+        connectionOptions: PostgresConnectionOptions,
+    ): Promise<import('pg').Client> {
+        const { Client } = require('pg');
+        const client = new Client({
+            host: connectionOptions.host,
+            port: connectionOptions.port,
+            user: connectionOptions.username,
+            password: connectionOptions.password,
+            database: 'postgres',
+        });
+        await client.connect();
+        return client;
+    }
+
+    private getDbNameFromFilename(filename: string): string {
+        return 'e2e_' + path.basename(filename).replace(/[^a-z0-9_]/gi, '_');
+    }
+}

+ 47 - 0
packages/testing/src/initializers/sqljs-initializer.ts

@@ -0,0 +1,47 @@
+import fs from 'fs';
+import path from 'path';
+import { SqljsConnectionOptions } from 'typeorm/driver/sqljs/SqljsConnectionOptions';
+
+import { Mutable } from '../types';
+
+import { TestDbInitializer } from './test-db-initializer';
+
+export class SqljsInitializer implements TestDbInitializer<SqljsConnectionOptions> {
+    private dbFilePath: string;
+    private connectionOptions: SqljsConnectionOptions;
+    constructor(private dataDir: string) {}
+
+    async init(
+        testFileName: string,
+        connectionOptions: SqljsConnectionOptions,
+    ): Promise<SqljsConnectionOptions> {
+        this.dbFilePath = this.getDbFilePath(testFileName);
+        this.connectionOptions = connectionOptions;
+        (connectionOptions as Mutable<SqljsConnectionOptions>).location = this.dbFilePath;
+        return connectionOptions;
+    }
+
+    async populate(populateFn: () => Promise<void>): Promise<void> {
+        if (!fs.existsSync(this.dbFilePath)) {
+            const dirName = path.dirname(this.dbFilePath);
+            if (!fs.existsSync(dirName)) {
+                fs.mkdirSync(dirName);
+            }
+            (this.connectionOptions as Mutable<SqljsConnectionOptions>).autoSave = true;
+            (this.connectionOptions as Mutable<SqljsConnectionOptions>).synchronize = true;
+            await populateFn();
+            (this.connectionOptions as Mutable<SqljsConnectionOptions>).autoSave = false;
+        }
+    }
+
+    destroy(): void | Promise<void> {
+        return undefined;
+    }
+
+    private getDbFilePath(testFileName: string) {
+        // tslint:disable-next-line:no-non-null-assertion
+        const dbFileName = path.basename(testFileName) + '.sqlite';
+        const dbFilePath = path.join(this.dataDir, dbFileName);
+        return dbFilePath;
+    }
+}

+ 46 - 0
packages/testing/src/initializers/test-db-initializer.ts

@@ -0,0 +1,46 @@
+import { ConnectionOptions } from 'typeorm';
+import { BaseConnectionOptions } from 'typeorm/connection/BaseConnectionOptions';
+
+/**
+ * @description
+ * Defines how the e2e TestService sets up a particular DB to run a single test suite.
+ * The `\@vendure/testing` package ships with initializers for sql.js, MySQL & Postgres.
+ *
+ * Custom initializers can be created by implementing this interface and registering
+ * it with the {@link registerInitializer} function:
+ *
+ * @example
+ * ```TypeScript
+ * export class CockroachDbInitializer implements TestDbInitializer<CockroachConnectionOptions> {
+ *     // database-specific implementation goes here
+ * }
+ *
+ * registerInitializer('cockroachdb', new CockroachDbInitializer());
+ * ```
+ *
+ * @docsCategory testing
+ */
+export interface TestDbInitializer<T extends BaseConnectionOptions> {
+    /**
+     * @description
+     * Responsible for creating a database for the current test suite.
+     * Typically, this method will:
+     *
+     * * use the testFileName parameter to derive a database name
+     * * create the database
+     * * mutate the `connetionOptions` object to point to that new database
+     */
+    init(testFileName: string, connectionOptions: T): Promise<T>;
+
+    /**
+     * @description
+     * Execute the populateFn to populate your database.
+     */
+    populate(populateFn: () => Promise<void>): Promise<void>;
+
+    /**
+     * @description
+     * Clean up any resources used during the init() phase (i.e. close open DB connections)
+     */
+    destroy(): void | Promise<void>;
+}

+ 13 - 133
packages/testing/src/test-server.ts

@@ -2,14 +2,10 @@ import { INestApplication, INestMicroservice } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
 import { DefaultLogger, Logger, VendureConfig } from '@vendure/core';
 import { preBootstrapConfig } from '@vendure/core/dist/bootstrap';
-import fs from 'fs';
-import path from 'path';
-import { MysqlConnectionOptions } from 'typeorm/driver/mysql/MysqlConnectionOptions';
-import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions';
-import { SqljsConnectionOptions } from 'typeorm/driver/sqljs/SqljsConnectionOptions';
 
 import { populateForTesting } from './data-population/populate-for-testing';
-import { Mutable, TestServerOptions } from './types';
+import { getInitializerFor } from './initializers/initializers';
+import { TestServerOptions } from './types';
 
 // tslint:disable:no-console
 /**
@@ -35,19 +31,18 @@ export class TestServer {
     async init(options: TestServerOptions): Promise<void> {
         const { type } = this.vendureConfig.dbConnectionOptions;
         const { dbConnectionOptions } = this.vendureConfig;
-        switch (type) {
-            case 'sqljs':
-                await this.initSqljs(options);
-                break;
-            case 'mysql':
-                await this.initMysql(dbConnectionOptions as MysqlConnectionOptions, options);
-                break;
-            case 'postgres':
-                await this.initPostgres(dbConnectionOptions as PostgresConnectionOptions, options);
-                break;
-            default:
-                throw new Error(`The TestServer does not support the database type "${type}"`);
+        const testFilename = this.getCallerFilename(1);
+        const initializer = getInitializerFor(type);
+        try {
+            await initializer.init(testFilename, dbConnectionOptions);
+            const populateFn = () => this.populateInitialData(this.vendureConfig, options);
+            await initializer.populate(populateFn);
+            await initializer.destroy();
+        } catch (e) {
+            console.error(e);
+            process.exit(1);
         }
+
         const [app, worker] = await this.bootstrapForTesting(this.vendureConfig);
         if (app) {
             this.app = app;
@@ -74,111 +69,6 @@ export class TestServer {
         }
     }
 
-    private async initSqljs(options: TestServerOptions) {
-        const dbFilePath = this.getDbFilePath(options.dataDir);
-        (this.vendureConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).location = dbFilePath;
-        if (!fs.existsSync(dbFilePath)) {
-            if (options.logging) {
-                console.log(`Test data not found. Populating database and caching...`);
-            }
-            await this.populateInitialData(this.vendureConfig, options);
-        }
-        if (options.logging) {
-            console.log(`Loading test data from "${dbFilePath}"`);
-        }
-    }
-
-    private async initMysql(connectionOptions: MysqlConnectionOptions, options: TestServerOptions) {
-        const filename = this.getCallerFilename(2);
-        const dbName = this.getDbNameFromFilename(filename);
-        const conn = await this.getMysqlConnection(connectionOptions);
-        (connectionOptions as any).database = dbName;
-        (connectionOptions as any).synchronize = true;
-        await new Promise((resolve, reject) => {
-            conn.query(`DROP DATABASE IF EXISTS ${dbName}`, err => {
-                if (err) {
-                    console.log(err);
-                    reject(err);
-                    return;
-                }
-                resolve();
-            });
-        });
-        await new Promise((resolve, reject) => {
-            conn.query(`CREATE DATABASE IF NOT EXISTS ${dbName}`, err => {
-                if (err) {
-                    console.log(err);
-                    reject(err);
-                    return;
-                }
-                resolve();
-            });
-        });
-        await this.populateInitialData(this.vendureConfig, options);
-        conn.destroy();
-    }
-
-    private async initPostgres(connectionOptions: PostgresConnectionOptions, options: TestServerOptions) {
-        const filename = this.getCallerFilename(2);
-        const dbName = this.getDbNameFromFilename(filename);
-        (connectionOptions as any).database = dbName;
-        (connectionOptions as any).synchronize = true;
-        const client = await this.getPostgresConnection(connectionOptions);
-        await client.query(`DROP DATABASE IF EXISTS ${dbName}`);
-        await client.query(`CREATE DATABASE ${dbName}`);
-        await this.populateInitialData(this.vendureConfig, options);
-        await client.end();
-    }
-
-    private async getMysqlConnection(
-        connectionOptions: MysqlConnectionOptions,
-    ): Promise<import('mysql').Connection> {
-        const { createConnection } = await import('mysql');
-        const conn = createConnection({
-            host: connectionOptions.host,
-            port: connectionOptions.port,
-            user: connectionOptions.username,
-            password: connectionOptions.password,
-        });
-        await new Promise((resolve, reject) => {
-            conn.connect(err => {
-                if (err) {
-                    reject(err);
-                    return;
-                }
-                resolve();
-            });
-        });
-        return conn;
-    }
-
-    private async getPostgresConnection(
-        connectionOptions: PostgresConnectionOptions,
-    ): Promise<import('pg').Client> {
-        const { Client } = require('pg');
-        const client = new Client({
-            host: connectionOptions.host,
-            port: connectionOptions.port,
-            user: connectionOptions.username,
-            password: connectionOptions.password,
-            database: 'postgres',
-        });
-        await client.connect();
-        return client;
-    }
-
-    private getDbFilePath(dataDir: string) {
-        // tslint:disable-next-line:no-non-null-assertion
-        const testFilePath = this.getCallerFilename(3);
-        const dbFileName = path.basename(testFilePath) + '.sqlite';
-        const dbFilePath = path.join(dataDir, dbFileName);
-        return dbFilePath;
-    }
-
-    private getDbNameFromFilename(filename: string): string {
-        return 'e2e_' + path.basename(filename).replace(/[^a-z0-9_]/gi, '_');
-    }
-
     private getCallerFilename(depth: number): string {
         let pst: ErrorConstructor['prepareStackTrace'];
         let stack: any;
@@ -209,12 +99,6 @@ export class TestServer {
         testingConfig: Required<VendureConfig>,
         options: TestServerOptions,
     ): Promise<void> {
-        const isSqljs = testingConfig.dbConnectionOptions.type === 'sqljs';
-        if (isSqljs) {
-            (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).autoSave = true;
-            (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).synchronize = true;
-        }
-
         const [app, worker] = await populateForTesting(testingConfig, this.bootstrapForTesting, {
             logging: false,
             ...options,
@@ -223,10 +107,6 @@ export class TestServer {
             await worker.close();
         }
         await app.close();
-
-        if (isSqljs) {
-            (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).autoSave = false;
-        }
     }
 
     /**

+ 0 - 6
packages/testing/src/types.ts

@@ -12,12 +12,6 @@ export type Mutable<T> = { -readonly [K in keyof T]: T[K] };
  * @docsCategory testing
  */
 export interface TestServerOptions {
-    /**
-     * @description
-     * The directory in which the populated SQLite database files will be
-     * saved. These files are a cache to speed up subsequent runs of e2e tests.
-     */
-    dataDir: string;
     /**
      * @description
      * The path to a CSV file containing product data to import.