Skip to main content

Testing

Testing

Implementing unit and integration tests is a proven way to make sure that your application behaves the way you expect it to. In many frameworks this is often a cumbersome and complicated experience, but not in Basica. You are always free to test your own code any way you want to, and if you need to test the interactions with Basica we got you covered.

Let's take the following application as a starting example.

sample.ts
import { ILogger } from "@basica/core/logger"
import { IStartup } from "@basica/core"

export type Config = {
msg: string
}

export const createSample = (logger: ILogger, config: Config) => ({
start: async () => {
deps.logger.info(config.msg);
},
sum: (n1: number, n2: number) => n1 + n2,
}) satisfies IStartup & Record<string, unknown>;

index.ts
import { loggerFactory, loggerConfigSchema } from "@basica/core/logger"
import { AppBuilder } from "@basica/core"
import { run } from "@basica/platform-node";
import { configure, envProvider } from "@basica/config"

import { z } from "zod"

import { createSample } from "./sample"

const config = configure(
envProvider(),
z.object({
logger: loggerConfigSchema,
sample: z.object({
msg: z.string()
})
})
)

const app = AppBuilder.registerDependencies((di) =>
di
.addSingleton("logger", () => loggerFactory(config.logger))
.addSingleton("sample", (deps) => createSample(deps.logger, config.sample)),
)
.configureLifecycle((b) => b
.addService("sample", (deps) => deps.sample)
)
.build()

run(app)

We are of course, free from any constraint when trying to test sample.ts. Using vitest we could write the following:

sample.test.ts

import { loggerFactory } from "@basica/core/logger"

import { expect, test, vi, beforeEach } from "vitest"

import { createSample, Config } from "./sample"

const logger = loggerFactory({ level: "silent" })
const config = { msg: "Hello" } satisfies Config
const sample = createSample(logger, config)

beforeEach(() => {
vi.clearAllMocks()
})

test("start", async () => {
const spy = vi.spyOn(logger, "info")

expect(spy).toHaveBeenCalledWith(config.msg)
})

test.each([
[2, 2, 4]
[1, -1, 0]
[1, -10, -9]
])("sum %d + %d = %d", (n1, n2, expected) => {
const result = sample.sum(n1, n2)

expect(result).toEqual(expected)
})

Testing With Basica

But what if we want to test the wiring with Basica? We can easily do that by first moving the application boostrap in its own file.

app.ts
import { loggerFactory, loggerConfigSchema } from "@basica/core/logger"
import { AppBuilder } from "@basica/core"
import { configure, envProvider, ConfigProvider } from "@basica/config"

import { z } from "zod"

import { createSample } from "./sample"

const configSchema = z.object({
logger: loggerConfigSchema,
sample: z.object({
msg: z.string()
})
})

export type Config = z.infer<typeof configSchema>

export const getApp = (provider: ConfigProvider = envProvider()) => {
const config = configure(provider, configSchema);

return AppBuilder.registerDependencies((di) =>
di
.addSingleton("logger", () => loggerFactory(config.logger))
.addSingleton("sample", (deps) => createSample(deps.logger, config.sample)),
)
.configureLifecycle((b) => b
.addService("sample", (deps) => deps.sample)
)
.build()
}
index.ts
import { getApp } from "./app"
import { run } from "@basica/platform-node";

const app = getApp()

run(app)

Now, we can call getApp in our tests, passing another config provider.

testConfig.ts
import { ConfigProvider } from "@basica/config"
import { Config } from "./app"

export const config = {
sample: {
msg: "Hello",
},
logger: {
level: "silent",
},
} satisfies Config

export const testConfigProvider = {
get: () => config,
} satisfies ConfigProvider

We are now able to check if the service starts and stops correctly. We can also use the other exposed properties to check if our service was called properly during the startup.

app.test.ts
import { expect, test, vi, beforeEach } from "vitest"

import { getApp } from "./app"
import { config, testConfigProvider } from "./testConfig"

beforeEach(() => {
vi.clearAllMocks()
})

test("start/stop", async () => {
const app = getApp(testConfigProvider)

const started = await app.lifecycle.start();
const stopped = await app.lifecycle.stop();

expect(started).toBe(true)
expect(stopped).toBe(true)
})

test("sample called correctly", async () => {
const app = getApp(testConfigProvider)

// app exposes deps, healthchecks, services and entrypoints
const spy = vi.spyOn(app.services.sample, "start")

await app.lifecycle.start();

expect(spy).toHaveBeenCalledWith(config.msg)
})

note

Healthchecks/services/entrypoints registered through a plugin are typed on the App as long as the plugin advertises its registrations (all official plugins do, see Writing plugins for more info).

app.ts
import { lifecyclePlugin } from "@basica/fastify"

//...

export const getApp = (provider: ConfigProvider = envProvider()) => {

// ...
return AppBuilder.registerDependencies()
.configureLifecycle((b) => b
.with(lifecyclePlugin, (b) =>
b.addFastifyEntrypoint("http", (b) => {
//...
})
)
)
.build()
}
app.test.ts
//...

const app = getApp(testConfigProvider)

type _ = typeof app.entrypoints.http // FastifyEntrypoint
const { fastify } = app.entrypoints.http

//...

test("GET /hello - 200" async () => {
const response = await fastify.inject("/hello")
expect(response.statusCode).toEqual(200)
})

//...