Skip to content

Commit

Permalink
feat(testing): Modularize DB support for e2e tests
Browse files Browse the repository at this point in the history
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,
        });
    });

    //...
});
```
  • Loading branch information
michaelbromley committed Jan 28, 2020
1 parent 50bdbd8 commit f8060b5
Show file tree
Hide file tree
Showing 9 changed files with 260 additions and 142 deletions.
27 changes: 24 additions & 3 deletions docs/content/docs/developer-guide/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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();
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/testing/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 26 additions & 0 deletions packages/testing/src/initializers/initializers.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 51 additions & 0 deletions packages/testing/src/initializers/mysql-initializer.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 48 additions & 0 deletions packages/testing/src/initializers/postgres-initializer.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 47 additions & 0 deletions packages/testing/src/initializers/sqljs-initializer.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 46 additions & 0 deletions packages/testing/src/initializers/test-db-initializer.ts
Original file line number Diff line number Diff line change
@@ -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>;
}
Loading

0 comments on commit f8060b5

Please sign in to comment.