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.
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.