Can I "include" or exec a TypeScript file in a Jest test?

Viewed 475

I have a TypeScript project that runs a backend RESTful API using Express. It is very object-heavy by design, so that lots of classes can be instantiated and injected into each other both at run time and when services classes are tested.

We have a good suite of tests across all service classes. However, we have an index.ts that brings this all together, and this presently escapes test automation. I am mulling a variety of approaches to testing this, so that endpoints and lightweight controllers are insulated against regressions. (Rather than list all my ideas that might result in an overly broad question, I shall focus on one specific idea for now).

Let me show an example of my front controller (src/index.ts):

/* Lots of imports here */

const app = express();
app.use(express.json());
app.use(cors());

app.options('*', cors());

/* Lots of settings from env vars here */

// Build some modules
const verificationsTableName = 'SV-Verifications';
const verificationsIndexName = 'SV-VerificationsByUserId';
const getVerificationService = new GetVerification(
    docClient,
    verificationsTableName,
    verificationsIndexName,
    timer,
    EXPIRY_LENGTH,
);
const writeVerifiedStatusService = new WriteVerifiedStatus(
    docClient,
    verificationsTableName,
    timer,
    getVerificationService,
);

/* Some code omitted for brevity */

// Create some routes
GetVerificationController.createRoutes(getVerificationService, app);
FinishVerificationController.createRoutes(finishVerificationService, app);
addPostStartVerification(startVerification, app);

IsVerifiedController.createValidationRoutes(di2.createOverallFeatureFlagService(), getVerificationService, app);

app.listen(PORT, () => {
    console.log(`⚡️[server]: Server is running at http://localhost:${PORT}`);
});

You get the idea - classes are assembled using dependency injection, we fetch some config from env vars, and we start the HTTP listener. The major point to notice is that this file doesn't contain or export any classes or functions.

I'd like to run this file in a Jest test suite, something like this:

describe('Test endpoint wiring', () => {
    beforeEach(() => {
        // Set up lots of env vars
        // How to run `src/index.ts` here?
    });

    afterEach(() => {
        // Tear down the server here
    });

    test('First endpoint test', () => {
        // Run a test against an endpoint
    });
});

I wonder if there is some sort of await exec('node command') I can do here? I would want it to run in the background so that tests run once the server has started. Ideally this would form part of the async thread in Jest, but if that is not possible, a straightforward process spawn would probably be fine.

It would be great if there was a reliable way to kill that at the end of each test (I suppose maintaining the PID and sending a stop signal is OK).

Modifying the index.ts is not out of the question (and indeed I am minded to stuff all this DI construction into a class, so that pieces can be replaced for testing purposes using simple method inheritance). But I would like to explore this no-changes option first.

3 Answers

If things are hard to test, that typically means you need to refactor to improve your encapsulation. You need to be able to create, execute, inspect, and teardown things multiple times in a test suite run. Which means that code that executes at the root level of a required file isn't really testable at all.

There's no magic here that will make this work*, you just need to refactor your code a bit. And if you do it right, it will make it easier to reason about how the application boots up in production, too.

Let's say you did something more like:

// Maybe move these to lib/constants.ts or something and import them instead.
const verificationsTableName = 'SV-Verifications';
const verificationsIndexName = 'SV-VerificationsByUserId';

export function boot(): Express {
  const app = createExpressApp()
  setupServices(app)
  startApp(app)
  return app
}

export function createExpressApp() {
  const app = express();
  // setup express app here
  return app
}

export function setupServices(app: Express) {
  setupGetVerificationService(app)
  setupFinishVerificationService(app)
  // call function that setup other services here.
}

export function setupGetVerificationService(app: Express) {
  const getVerificationService = new GetVerification(/* ... */)
  GetVerificationController.createRoutes(getVerificationService, app);
}

export function setupFinishVerificationService(app: Express) {
  const writeVerifiedStatusService = new WriteVerifiedStatus(/* ... */)
  FinishVerificationController.createRoutes(finishVerificationService, app)
}

export function startApp(app: Express) {
  app.listen(PORT, () => {
    console.log(`⚡️[server]: Server is running at http://localhost:${PORT}`);
  });
}

export function stopApp(app: Express) {
  app.close();
}

Now in your index.ts that boots your app in production, you can have simply:

import { boot } from './initialize-app.ts'

boot()

And now you can test each step of the app setup however you like:

describe('Test endpoint wiring', () => {
    let app: Express
    beforeEach(() => {
        // Set up lots of env vars
        app = createExpressApp()
        setupServices(app)
        startApp(app)
    });

    afterEach(() => {
        // Tear down the server here
        stopApp(app)
    });

    test('First endpoint test', () => {
        // Run a test against an endpoint
    });
});

With this structure you could now even only create a subset of services to test each one in isolation, which also may help find issues where services depend on each other where they shouldn't. And as a bonus, improve your test times as well.


* Yeah, you could manage your server as a separate process and hit its endpoints via HTTP, but I wouldn't really recommend it. Your life will be a lot easier if you keep it in one process and refactor how your app is created, configured, and destroyed. It's going to be a lot cleaner, and a lot more flexible.

If you'd like to keep the test suite completely separate and only test the HTTP endpoints using fetch, as a user would, you can use concurrently to achieve this.

concurrently -s first --kill-others \"yarn run serve\" \"yarn run tests:integration\"

When adding this command to my package.json, I can configure the serve part to setup the express server, and the tests:integration script to actually test the endpoints. The -s first makes the returning status of concurrently the same as the first process to exit, and --kill-others kills all processes once one of the process finishes.

See that I call these integration tests? The way I see it these kind of tests are more prone to error and they test this part of the solution as a whole, so they'd be in a middle level in the test pyramid. Hopefully you have way more unit tests that focus on testing specific classes/files/things one by one.

I have sketched out a process-based answer to my question:

import { ChildProcess, spawn } from 'child_process';
import fetch from 'node-fetch';

describe('Test endpoint wiring', () => {
    let listenerProcess: ChildProcess;

    beforeEach(async () => {
        // @todo Don't use absolute paths here
        const runner = '/root/app/node_modules/.bin/ts-node';
        const listener = '/root/app/src/index.ts';
        listenerProcess = spawn(runner, [listener], {
            stdio: 'ignore',
            detached: true,
            env: {
                PORT: '9001',
                NODE_PATH: '/root/app/src',
            },
        });
        listenerProcess.unref();
        console.log('PID: ', listenerProcess.pid);

        // @todo Add a retry loop to this
        await new Promise((resolve, reject) => {
            function later(delay) {
                return new Promise(function (resolve) {
                    setTimeout(resolve, delay);
                });
            }

            later(4000)
                .then(() => {
                    console.log('Trying spawned listener');
                    return fetch('http://localhost:9001/');
                })
                .then(() => {
                    console.log('Listener seems to be up');
                    resolve(null);
                })
                .catch((error) => {
                    // Ignore errors (e.g. can't connect)
                    console.log('Cannot connect');
                });

            console.log('Entered promise handler');
        });
    });

    afterEach(() => {
        console.log('Do a task kill here');
        listenerProcess.kill();
    });

    test('First endpoint test', async () => {
        // FIXME Just demo code
        try {
            const response = await fetch('http://localhost:9001/');
            console.log('HTTP response code:', response.status);
            //console.log(response.headers);
            console.log(await response.text()); // Not sure why this needs another await
        } catch (e) {
            console.error('Error has occurred:', e);
        }
    });
});

You can see it uses a global listenerProcess to contain the spawned index.ts listener, and in turn the behaviour of that script is modified to test mode by virtual of env vars passed to spawn().

I used this answer to help disconnect the process from the test parent, so that there was no hanging. You can see that I spin the listener up on 9001 (9000 is for my dev one, which is often running in the same container). In practice I would make this port 9001 + random, to avoid any parallel tests setting up conflicting listeners.

As you can see I also kill the process after each test too. The idea here is that each test gets a clean listener, in case there are any artifacts that remain in the listening architecture between tests.

My observation so far is that while this seems to print a PID reliably, there are sometimes still problems with the listener, which is still not ready after a four-second start delay. You can see from the code that I planned to implement some retry code, and if I were to persist in this direction, I would certainly do that. However, Alex's solution strikes me as much more robust, so I am liable to take that direction for now.

Related