How to ensure the Jest bootstrap file is run first?

陌路散爱 提交于 2021-02-11 13:58:19

问题


Introduction

I am getting stuck with database Promises I am using inside the Jest testing framework. Things are running in the wrong order, and following some of my latest changes, Jest is not finishing correctly because an unknown asynchronous operation is not being handled. I am fairly new to Node/Jest.

Here is what I am trying to do. I am setting up Jest inside a multiple Docker container environment to call internal APIs in order to test their JSON outputs, and to run service functions in order to see what change they make against a test-environment MySQL database. To do that, I am:

  • using the setupFilesAfterEnv Jest configuration option to point to a setup file, which I believe should be run first
  • using the setup file to destroy the test database (if it exists), to recreate it, and then to create some tables
  • using mysql2/promise to carry out operations on the database
  • using a beforeEach(() => {}) in a test to truncate all tables, in readiness for inserting per-test data, so that tests are not dependent on each other

I can confirm that the setup file for Jest is being run before the first (and only) test file, but what is odd is that a Promise catch() in the test file appears to be thrown before a finally in the setup file.

I will put my code down first, and then speculate on what I vaguely suspect to be a problem.

Code

Here is the setup file, nice and straightforward:

// Little fix for Jest, see https://stackoverflow.com/a/54175600
require('mysql2/node_modules/iconv-lite').encodingExists('foo');

// Let's create a database/tables here
const mysql = require('mysql2/promise');
import TestDatabase from './TestDatabase';
var config = require('../config/config.json');

console.log('Here is the bootstrap');

const initDatabase = () => {
  let database = new TestDatabase(mysql, config);
  database.connect('test').then(() => {
    return database.dropDatabase('contributor_test');
  }).then(() => {
    return database.createDatabase('contributor_test');
  }).then(() => {
    return database.useDatabase('contributor_test');
  }).then(() => {
    return database.createTables();
  }).then(() => {
    return database.close();
  }).finally(() => {
    console.log('Finished once-off db setup');
  });
};
initDatabase();

The config.json is just usernames/passwords and not worth showing here.

As you can see this code uses a utility database class, which is this:

export default class TestDatabase {

  constructor(mysql, config) {
    this.mysql = mysql;
    this.config = config;
  }

  async connect(environmentName) {
    if (!environmentName) {
      throw 'Please supply an environment name to connect'
    }
    if (!this.config[environmentName]) {
      throw 'Cannot find db environment data'
    }

    const config = this.config[environmentName];
    this.connection = await this.mysql.createConnection({
      host: config.host, user: config.username,
      password: config.password,
      database: 'contributor'
    });
  }

  getConnection() {
    if (!this.connection) {
      throw 'Database not connected';
    }

    return this.connection;
  }

  dropDatabase(database) {
    return this.getConnection().query(
      `DROP DATABASE IF EXISTS ${database}`
    );
  }

  createDatabase(database) {
    this.getConnection().query(
      `CREATE DATABASE IF NOT EXISTS ${database}`
    );
  }

  useDatabase(database) {
    return this.getConnection().query(
      `USE ${database}`
    );
  }

  getTables() {
    return ['contribution', 'donation', 'expenditure',
      'tag', 'expenditure_tag'];
  }

  /**
   * This will be replaced with the migration system
   */
  createTables() {
    return Promise.all(
      this.getTables().map(table => this.createTable(table))
    );
  }

  /**
   * This will be replaced with the migration system
   */
  createTable(table) {
    return this.getConnection().query(
      `CREATE TABLE IF NOT EXISTS ${table} (id INTEGER)`
    );
  }

  truncateTables() {
    return Promise.all(
      this.getTables().map(table => this.truncateTable(table))
    );
  }

  truncateTable(table) {
    return this.getConnection().query(
      `TRUNCATE TABLE ${table}`
    );
  }

  close() {
    this.getConnection().close();
  }

}

Finally, here is the actual test:

const mysql = require('mysql2/promise');
import TestDatabase from '../TestDatabase';
var config = require('../../config/config.json');

let database = new TestDatabase(mysql, config);

console.log('Here is the test class');


describe('Database tests', () => {

  beforeEach(() => {
    database.connect('test').then(() => {
      return database.useDatabase('contributor_test');
    }).then (() => {
      return database.truncateTables();
    }).catch(() => {
      console.log('Failed to clear down database');
    });
  });

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

  test('Describe this demo test', () => {
    expect(true).toEqual(true);
  });

});

Output

As you can see I have some console logs, and this is their unexpected order:

  1. "Here is the bootstrap"
  2. "Here is the test class"
  3. Tests finish here
  4. "Failed to clear down database"
  5. "Finished once-off db setup"
  6. Jest reports "Jest did not exit one second after the test run has completed. This usually means that there are asynchronous operations that weren't stopped in your tests."
  7. Jest hangs, requires ^C to exit

I want:

  1. "Here is the bootstrap"
  2. "Finished once-off db setup"
  3. "Here is the test class"
  4. No error when calling truncateTables

I suspect that the database error is that the TRUNCATE operations are failing because the tables do not exist yet. Of course, if the commands ran in the right order, they would!

Notes

I originally was importing mysql instead of mysql/promise, and found from elsewhere on Stack Overflow that without promises, one needs to add callbacks to each command. That would make the setup file messy - each of the operations connect, drop db, create db, use db, create tables, close would need to appear in a deeply nested callback structure. I could probably do it, but it is a bit icky.

I also tried writing the setup file using await against all the promise-returning db operations. However, that meant I had to declare initDatabase as async, which I think means I can no longer guarantee the whole of the setup file is run first, which is in essence the same problem as I have now.

I have noticed that most of the utility methods in TestDatabase return a promise, and I am pretty happy with those. However connect is an oddity - I want this to store the connection, so was confused about whether I could return a Promise, given that a Promise is not a connection. I have just tried using .then() to store the connection, like so:

    return this.mysql.createConnection({
      host: config.host, user: config.username,
      password: config.password
    }).then((connection) => {
      this.connection = connection;
    });

I wondered if that should work, since the thenable chain should wait for the connection promise to resolve before moving onto the next thing in the list. However, the same error is produced.

I briefly thought that using two connections might be a problem, in case tables created in one connection cannot be seen until that connection is closed. Building on that idea, maybe I should try connecting in the setup file and re-using that connection in some manner (e.g. by using mysql2 connection pooling). However my senses tell me that this is really a Promise issue, and I need to work out how to finish my db init in the setup file before Jest tries to move on to test execution.

What can I try next? I am amenable to dropping mysql2/promise and falling back to mysql if that is a better approach, but I'd rather persist with (and fully grok) promises if at all possible.


回答1:


You need to await your database.connect() in the beforeEach().




回答2:


I have a solution to this. I am not yet au fait with the subtleties of Jest, and I wonder if I have just found one.

My sense is that since there is no return value from the bootstrap to Jest, there is no way to notify to it that it needs to wait for promises to resolve before moving onto the tests. The result of that is that the promises are resolving during awaiting in the tests, which produces an absolute mess.

In other words, the bootstrap script can only be used for synchronous calls.

Solution 1

One solution is to move the thenable chain from the bootstrap file to a new beforeAll() hook. I converted the connect method to return a Promise, so it behaves like the other methods, and notably I have returned the value of the Promise chain in both the new hook and the existing one. I believe this notifies Jest that the promise needs to resolve before other things can happen.

Here is the new test file:

const mysql = require('mysql2/promise');
import TestDatabase from '../TestDatabase';
var config = require('../../config/config.json');

let database = new TestDatabase(mysql, config);

//console.log('Here is the test class');

beforeAll(() => {
  return database.connect('test').then(() => {
    return database.dropDatabase('contributor_test');
  }).then(() => {
    return database.createDatabase('contributor_test');
  }).then(() => {
    return database.useDatabase('contributor_test');
  }).then(() => {
    return database.createTables();
  }).then(() => {
    return database.close();
  }).catch((error) => {
    console.log('Init database failed: ' +  error);
  });
});

describe('Database tests', () => {

  beforeEach(() => {
    return database.connect('test').then(() => {
      return database.useDatabase('contributor_test');
    }).then (() => {
      return database.truncateTables();
    }).catch((error) => {
      console.log('Failed to clear down database: ' + error);
    });
  });

  /**
   * I wanted to make this non-async, but Jest doesn't seem to
   * like receiving a promise here, and it finishes with an
   * unhandled async complaint.
   */
  afterAll(() => {
    database.getConnection().close();
  });

  test('Describe this demo test', () => {
    expect(true).toEqual(true);
  });

});

In fact that can probably be simplified further, since the connection does not need to be closed and reopened.

Here is the non-async version of connect in the TestDatabase class to go with the above changes:

  connect(environmentName) {
    if (!environmentName) {
      throw 'Please supply an environment name to connect'
    }
    if (!this.config[environmentName]) {
      throw 'Cannot find db environment data'
    }

    const config = this.config[environmentName];

    return this.mysql.createConnection({
      host: config.host, user: config.username,
      password: config.password
    }).then(connection => {
      this.connection = connection;
    });
  }

The drawback with this solution is either:

  • I have to call this init code in every test file (duplicates work I only want to do once), or
  • I have to call this init code in the first test only (which is a bit brittle, I assume tests are run in alphabetical order?)

Solution 2

A rather more obvious solution is that I can put the database init code into a completely separate process, and then modify the package.json settings:

"test": "node ./bin/initdb.js && jest tests"

I have not tried that, but I am pretty sure that would work - even if the init code is JavaScript, it would have to finish all its async work before exiting.



来源:https://stackoverflow.com/questions/61637311/how-to-ensure-the-jest-bootstrap-file-is-run-first

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!