Mocktender: simplified testing using entry points

Hey folks. It's been a while since my last post... What have I been up to, you may ask? I have been struggling with testing. So I built this tool to simplify testing. And I'm here to share more about it. Problem I've learned to test behavior and to isolate integrations so the tests remain quick. But now, to test a resolver that creates a post, for example, I would need roughly 3 tests. The first test would start the server with mock handlers, ensuring the route calls the handler. The second test would call the handler directly with a DB data stub, ensuring the handler returns the created post. The third test would start the database, ensuring the query with fake data creates the post row. If we were to add notifying subscribers by email, we'd need another one! So, we need an isolated test for every integration. Yet, all these tests require maintenance. Introducing mocktender To address that, I built a tool that traces the code behavior across your layers and isolates the integrations. Instead of writing and maintaining 3+ tests, I only need 1. I just need to test the entry-point, which in this case is my server route. Mocktender traces how my server route interacts with the handler, how the handler interacts with the database method, and so on... Then, mocktender isolates them so that my single test runs similarly to those 3 traditional tests, with each integration isolated (without sacrificing speed). Example The code from this example can be found here. To demonstrate the example, we'll test a basic HTTP server with a route handler. We're only going to handle the /hello route; otherwise, we'll return 404 not found. // server.ts import http from "http" import { helloHandler } from "./handlers.ts" const requestHandler = (req: http.IncomingMessage, res: http.ServerResponse) => { if (req.url?.startsWith("/hello")) { const { status, body } = helloHandler(req.url) res.writeHead(status, { 'Content-Type': 'text/plain' }) res.end(body) return } res.writeHead(404, { 'Content-Type': 'text/plain' }) res.end('Not Found') } export function createServer(): http.Server { return http.createServer(requestHandler) } Now that we have the server, we want to implement the helloHandler. It accepts an optional query parameter name and returns 200 status with body Hello, ${name ?? 'world'}!. // handlers.ts import URL from "url" function greet(name: string = "world") { return `Hello, ${name}!` } /** @bridge bridges the server request handler with the greet function */ export function helloHandler(url: string): { status: number; body: string } { const parsed = URL.parse(url, true) const name = Array.isArray(parsed.query.name) ? parsed.query.name[0] : parsed.query.name return { status: 200, body: greet(name) } } Notice the @bridge tag we added above helloHandler? This helps mocktender identify where it should isolate the tests. We still need to test the entry-point's behavior. So, we'll start the server when we start testing, and close it when we're done testing. During the test, we'll call each server route. The following routes should cover all the behaviors of our codebase: /hello /hello?name=John /not-found // server.test.ts import { createServer } from "./server.ts" type TestCase = { desc: string url: string want: { status: number body: string } } const tt: TestCase[] = [ { desc: "greeting.default", url: "/hello", want: { status: 200, body: "Hello, world!" } }, { desc: "greeting.withName", url: "/hello?name=John", want: { status: 200, body: "Hello, John!" } }, { desc: "greeting.notFound", url: "/not-found", want: { status: 404, body: "Not Found" } } ] // This test runs as both: end to end, and isolated integration test. // Test the behavior, record it, and then replay it. // Replaying the test is great for CI and isolating fixtures. describe("server", () => { const server = createServer() beforeAll(() => { server.listen(8080) }) afterAll(() => { server.close() }) it.each(tt)("$desc", async (tc) => { global.__rid = tc.desc const res = await fetch(`http://localhost:8080${tc.url}`) const gotStatus = res.status expect(gotStatus).toBe(tc.want.status) const gotBody = await res.text() expect(gotBody).toBe(tc.want.body) }) }) Next up, I need to record the behavior of my codebase. After adding mocktender's Jest plugin, I can run the following command. yarn test:record As a result, the following behaviors file gets generated. This file is tracked by version control, so you can know exactly what changes whenever reviewing a pull request. { "/app/example/handlers.ts": { "helloHandler": { // the `@bridge` tagged function "greeting.default": [ // the first testcase {

Mar 25, 2025 - 15:21
 0
Mocktender: simplified testing using entry points

Hey folks. It's been a while since my last post...

What have I been up to, you may ask?
I have been struggling with testing.

So I built this tool to simplify testing.
And I'm here to share more about it.

Problem

I've learned to test behavior and to isolate integrations so the tests remain quick.

But now, to test a resolver that creates a post, for example, I would need roughly 3 tests.

  1. The first test would start the server with mock handlers, ensuring the route calls the handler.
  2. The second test would call the handler directly with a DB data stub, ensuring the handler returns the created post.
  3. The third test would start the database, ensuring the query with fake data creates the post row.

If we were to add notifying subscribers by email, we'd need another one! So, we need an isolated test for every integration.

Yet, all these tests require maintenance.

Introducing mocktender

To address that, I built a tool that traces the code behavior across your layers and isolates the integrations.

Instead of writing and maintaining 3+ tests, I only need 1. I just need to test the entry-point, which in this case is my server route.

Mocktender traces how my server route interacts with the handler, how the handler interacts with the database method, and so on...

Then, mocktender isolates them so that my single test runs similarly to those 3 traditional tests, with each integration isolated (without sacrificing speed).

Example

The code from this example can be found here.

To demonstrate the example, we'll test a basic HTTP server with a route handler.

We're only going to handle the /hello route; otherwise, we'll return 404 not found.

// server.ts
import http from "http"

import { helloHandler } from "./handlers.ts"

const requestHandler = (req: http.IncomingMessage, res: http.ServerResponse) => {
  if (req.url?.startsWith("/hello")) {
    const { status, body } = helloHandler(req.url)
    res.writeHead(status, { 'Content-Type': 'text/plain' })
    res.end(body)
    return
  }

  res.writeHead(404, { 'Content-Type': 'text/plain' })
  res.end('Not Found')
}

export function createServer(): http.Server {
  return http.createServer(requestHandler)
}

Now that we have the server, we want to implement the helloHandler.

It accepts an optional query parameter name and returns 200 status with body Hello, ${name ?? 'world'}!.

// handlers.ts
import URL from "url"

function greet(name: string = "world") {
  return `Hello, ${name}!`
}

/** @bridge bridges the server request handler with the greet function */
export function helloHandler(url: string): { status: number; body: string } {
  const parsed = URL.parse(url, true)

  const name = Array.isArray(parsed.query.name)
    ? parsed.query.name[0]
    : parsed.query.name

  return {
    status: 200,
    body: greet(name)
  }
}

Notice the @bridge tag we added above helloHandler?
This helps mocktender identify where it should isolate the tests.

We still need to test the entry-point's behavior.

So, we'll start the server when we start testing, and close it when we're done testing.

During the test, we'll call each server route. The following routes should cover all the behaviors of our codebase:

  1. /hello
  2. /hello?name=John
  3. /not-found
// server.test.ts

import { createServer } from "./server.ts"

type TestCase = {
  desc: string
  url: string
  want: {
    status: number
    body: string
  }
}

const tt: TestCase[] = [
  {
    desc: "greeting.default",
    url: "/hello",
    want: {
      status: 200,
      body: "Hello, world!"
    }
  },
  {
    desc: "greeting.withName",
    url: "/hello?name=John",
    want: {
      status: 200,
      body: "Hello, John!"
    }
  },
  {
    desc: "greeting.notFound",
    url: "/not-found",
    want: {
      status: 404,
      body: "Not Found"
    }
  }
]

// This test runs as both: end to end, and isolated integration test.
// Test the behavior, record it, and then replay it.
// Replaying the test is great for CI and isolating fixtures.
describe("server", () => {
  const server = createServer()

  beforeAll(() => {
    server.listen(8080)
  })

  afterAll(() => {
    server.close()
  })

  it.each(tt)("$desc", async (tc) => {
    global.__rid = tc.desc

    const res = await fetch(`http://localhost:8080${tc.url}`)

    const gotStatus = res.status
    expect(gotStatus).toBe(tc.want.status)

    const gotBody = await res.text()
    expect(gotBody).toBe(tc.want.body)
  })
})

Next up, I need to record the behavior of my codebase.
After adding mocktender's Jest plugin, I can run the following command.

yarn test:record

As a result, the following behaviors file gets generated.

This file is tracked by version control, so you can know exactly what changes whenever reviewing a pull request.

{
    "/app/example/handlers.ts": {
        "helloHandler": {           // the `@bridge` tagged function
            "greeting.default": [   // the first testcase
                {
                    "args": [
                        "/hello"
                    ],
                    "result": {
                        "status": 200,
                        "body": "Hello, world!"
                    }
                }
            ],
            "greeting.withName": [   // the second testcase
                {
                    "args": [
                        "/hello?name=John"
                    ],
                    "result": {
                        "status": 200,
                        "body": "Hello, John!"
                    }
                }
            ]
        }
    }
}

Finally, whenever I need to simulate the test run again, I can just run the following command.

yarn test:replay

Disclaimer

It’s in early proof-of-concept stage, dogfooding, with a working example.

It has a long way to go, so I'd love feedback/contributions—especially on a subject like testing.

GitHub logo nizarmah / mocktender

Simplified testing using entry-points.

mocktender

Serves mocks.

Mocktender is a test tracer that automatically records code behavior and replays it later, simplifying tests and mocks.

For example, testing a create post resolver, traditionally I would:

  1. Test the resolver with a DB stub.
  2. Test the DB integration with fake input.
  3. Test server integration with a resolver mock.

Using mocktender, I would only need to test the entrypoint (the server route). Then:

  1. Mocktender records the behavior across the server, resolver, and DB.
  2. Mocktender automatically isolates each to match traditional tests.

Check example.

Early development

Only a proof of concept right now, but actively being developed.

If you need it, let me know — nizar.mah99@gmail.com. It can be reproduced in different languages.

Objectives

  1. Test a codebase through its entrypoints.
  2. Eventually, only run codebase through tests.
  3. One day, simulate e2e tests with unit tests.

Technical guide

Check src.