'How can I wrap a Mocha or Jest test suite in a Node.js AsyncLocalStorage.run?

I have a test suite for a Node.js+express.js application that requires database access using Sequelize. My previous experience was in Python+Django. In Django, there is a nice TestCase class that wraps each test in a transaction; the transaction is rolled back automatically at the end of each test. This is a good idea to ensure data-wise isolation among tests.

I want to replicate the same effect using Mocha or Jest. I get a feeling that there should be a way using Node.js (v16)'s AsyncLocalStorage, but I just can't get it to work.

My guess at the gist of it is something like this:

  • instantiate an AsyncLocalStorage in a module, and export it.
  • in my test file, import the AsyncLocalStorage instance; _ call Mocha's describe();
  • inside the callback for describe, create a Sequelize transaction;
  • inside the callback for the Sequelize transaction:
    • store the transaction in the AsyncLocalStorage instance
    • call Mocha's before hook; then, inside the callback for the before hook:
      • create a Sequelize transaction checkpoint
      • insert some fixture rows into the database using that checkpoint
    • call Mocha's after book; then, inside the callback for the after hook:
      • rollback the checkpoint
    • call Mocha's it a couple of times to define each test; inside each test:
      • call some application functions that I want to test; each of these functions will:
        • import the AsyncLocalStorage instance and try to find a transaction in it
        • if a transaction is found in AsyncLocalStorage, create another checkpoint on it and use it
        • otherwise start a new transaction

Doing so would allow the application functions to see the fixture data that I inserted in Mocha's before hook. And then the checkpoint rollback in Mocha's after hook would just clean everything up. I wouldn't have to write my own SQL DELETE queries, for example, and clean up would always be correct. I wouldn't have to worry about one test suite accidentally leaving data that contaminates the next test suite (which would cause a lot of head scratching).

I tried two different ways, but none of them worked (the tests wouldn't actually get run).

Attempt 1:

import { QueryTypes, Transaction } from 'sequelize';

import { applicationFunction1, applicationFunction2 } from '../src/blabla';
import { sequelize, transactionStorage } from '../src/infrastructure/orm';
import { insertFixtureSQL } from './fixture'

const map = new Map<string, Transaction>();

describe('My super awesome test suite', async function() {
  // eslint-disable-next-line consistent-this,babel/no-invalid-this
  const mocha = this;

  // `transactionStorage` is an AsyncLocalStorage instance
  // start an AsyncLocalStorage run by giving it the map;
  // other async functions down the chain should be able to retrieve the same map;
  await transactionStorage.run(map, async () => {
    // `sequelize` is a `Sequelize` instance
    await sequelize.transaction(async (transaction) => {
      // put the transaction in the map
      map.set('transaction', transaction);

      mocha.beforeEach(async () => {
        // Create a checkpoint
        const checkpoint = await sequelize.transaction({ transaction });

        // Replace the transaction with the checkpoint (which itself is a sequelize Transaction instance)
        map.set('transaction', checkpoint);

        // Insert fixture data using the checkpoint;
        // `insertFixtureSQL` is a string that contains an SQL INSERT statement.
        await sequelize.query(insertFixtureSQL, { type: QueryTypes.INSERT, raw: true, transaction: checkpoint });
      });
      mocha.afterEach(async () => {
        const checkpoint = map.get('transaction') as Transaction;

        // If I just rollback the checkpoint, everything shall be cleaned up
        await checkpoint.rollback();
      });

      it('My awesome test 1', async () => {
        // inside `applicationFunction1()`, it will try to find the transaction from the
        // `transactionStorage`;
        // if found, it will create another checkpoint on it and use it;
        // otherwise, it will create a new Sequelize transaction.
        await applicationFunction1();
      });

      it('My awesome test 2', async () => {
        // inside `applicationFunction2()`, it will try to find the transaction from the
        // `transactionStorage`;
        // if found, it will create another checkpoint on it and use it;
        // otherwise, it will create a new Sequelize transaction.
        await applicationFunction2();
      });
    });
  });
});

Attempt 2:

import { QueryTypes, Transaction } from 'sequelize';

import { applicationFunction1, applicationFunction2 } from '../src/blabla';
import { sequelize, transactionStorage } from '../src/infrastructure/orm';
import { insertFixtureSQL } from './fixture'

const map = new Map<string, Transaction>();

// `transactionStorage` is an AsyncLocalStorage instance
// start an AsyncLocalStorage run by giving it the map;
// other async functions down the chain should be able to retrieve the same map;
transactionStorage.run(map, async () => {
  // `sequelize` is a `Sequelize` instance
  await sequelize.transaction(async (transaction) => {
    // put the transaction in the map
    map.set('transaction', transaction);

    describe('My super awesome test suite', async function() {
      this.beforeEach(async () => {
        // Create a checkpoint
        const checkpoint = await sequelize.transaction({ transaction });

        // Replace the transaction with the checkpoint (which itself is a sequelize Transaction instance)
        map.set('transaction', checkpoint);

        // Insert fixture data using the checkpoint;
        // `insertFixtureSQL` is a string that contains an SQL INSERT statement.
        await sequelize.query(insertFixtureSQL, { type: QueryTypes.INSERT, raw: true, transaction: checkpoint });
      });
      this.afterEach(async () => {
        const checkpoint = map.get('transaction') as Transaction;

        // If I just rollback the checkpoint, everything shall be cleaned up
        await checkpoint.rollback();
      });

      it('My awesome test 1', async () => {
        // inside `applicationFunction1()`, it will try to find the transaction from the
        // `transactionStorage`;
        // if found, it will create another checkpoint on it and use it;
        // otherwise, it will create a new Sequelize transaction.
        await applicationFunction1();
      });

      it('My awesome test 2', async () => {
        // inside `applicationFunction2()`, it will try to find the transaction from the
        // `transactionStorage`;
        // if found, it will create another checkpoint on it and use it;
        // otherwise, it will create a new Sequelize transaction.
        await applicationFunction2();
      });
    });
  });
});

The difference between the two attempts is the order of the wrapping:

  • Attempt 1:
    1. Mocha's describe()
    2. transactionStorage.run()
    3. sequelize.transaction()
    4. Mocha's beforeEach/afterEach/it
  • Attempt 2:
    1. transactionStorage.run()
    2. sequelize.transaction()
    3. Mocha's describe()
    4. Mocha's beforeEach/afterEach/it

What happens when I try to run my test suite:



  0 passing (1ms)

info:    Executing (55fc411e-2a51-4095-9279-aec3942dc818): START TRANSACTION;
info:    Executing (55fc411e-2a51-4095-9279-aec3942dc818): COMMIT;

Which means it didn’t actually find any test to run.



Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source