0

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.

halfer
  • 19,824
  • 17
  • 99
  • 186
  • I am getting some off-site feedback that the `beforeEach()` needs to return its content, to notify Jest that it is doing something async. I will try that too. – halfer May 06 '20 at 14:51

2 Answers2

1

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

Ashley
  • 897
  • 1
  • 5
  • 17
  • OK, that's a great start - appreciated. Jest now completes and there are no complaints about unhandled async operations. However, I have added some strategic console logging into both of my thenable chains, and I find that the table truncation in the test is being attempted prior to the table creation in the setup file. Somehow I need to get Jest to wait for the setup file to finish, rather than letting an async request finish (and technically still remain pending) before it starts the tests. – halfer May 06 '20 at 14:29
  • I could try using the non-Promise version of the database library, but as I understand it - and that is not very much admittedly - that would require a deep tree of callbacks. I'm not even sure Jest would wait for them to return, either! – halfer May 06 '20 at 14:31
  • I could try something horrible like a loop at the end of the setup file, which repeatedly tests the state of the database (or maybe a flag set by a Promise `finally()`). But that's even ickier than nested callbacks `:=p`. – halfer May 06 '20 at 14:33
  • I am pondering whether the problem is essentially in Jest, and that I should do all my setup in a `beforeAll()` in every test, rather than attempting to run it from the bootstrap. Maybe this just isn't equipped to wait for a async op to finish? – halfer May 06 '20 at 14:59
0

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.

halfer
  • 19,824
  • 17
  • 99
  • 186
  • Just now had time to peek at this. Glad I was able to at least help! I guess my only other suggestion aside from your own is that you go ahead and change the setup file to have all the database calls `await` and then also `await` the call to `initDatabase()`... but nice work! – Ashley May 06 '20 at 17:46
  • Indeed @Ashley, I think between you and a friend not on Stack Overflow, enough rubber duck inspiration was provided to fix this! He also suggested [this feature](https://jestjs.io/docs/en/configuration#globalsetup-string), which appears to support async calls, so I think I will try that too - it looks cleaner than my solutions provided so far. Nevertheless, I am out of the woods, so thank you. – halfer May 06 '20 at 17:49
  • I fear this is the difference between "knowing Jest" and "have been down in the trenches with Jest for a couple of years, and have the scars to prove it" `:=O` – halfer May 06 '20 at 17:50
  • @Ashley: in relation to using `await` in the setup file, I am pretty sure I tried that, but maybe my database utility class has metamorphosed since then, and if I were to retry it, it would now work. At any rate, I shall be quickly capturing this code in version control, since it sometimes feels Node code works only if the moon is in the correct phase of its orbit. – halfer May 06 '20 at 17:54
  • 1
    I'm still getting comfortable with Jest and Node as well, so I hear that. Glad you're set now, and happy Node-ing! – Ashley May 06 '20 at 18:09