Testing Deno Applications: Unit, Integration, and Coverage

After several months running a Deno application in production on DigitalOcean, I’ve learned a lot about testing in Deno. In my last post I talked about DI; here’s how I actually test those services. Deno’s built‑in testing tools are powerful and simple. You don’t need Jest, Mocha, or Istanbul, because Deno ships with a test runner, assertion helpers, mocking utilities, and coverage support out of the box.

In this post, I’ll share my approach to testing Deno applications, covering unit tests, integration tests, and test coverage. All examples come from my production codebase, a billboard management system built with Deno, Drizzle ORM, and MySQL.

Why Deno’s Testing Tools Matter

Coming from Node.js, I expected to need a testing framework, a mocking library, and a coverage tool, but Deno includes all of this as part of the runtime and standard library:

  • Built‑in test runner: No Jest or Mocha needed
  • Built‑in mocking utilities: No Sinon required
  • Built‑in coverage: No Istanbul or nyc needed

This isn’t just about fewer dependencies. It’s about consistency: everyone using Deno has access to the same testing tools and APIs, which makes examples and patterns easier to share and reason about.

Deno’s Built‑in Testing Framework

Let’s start with the basics. Deno’s test runner is simple and powerful:

import { assertEquals } from "jsr:@std/assert";

Deno.test("should calculate total correctly", () => {
  const result = calculateTotal([10, 20, 30]);
  assertEquals(result, 60);
});Code language: TypeScript (typescript)

That’s it. No setup, no configuration. Just write your test and run:

deno testCode language: Bash (bash)

When you’re iterating on a specific area, you can filter by name or file, for example:

deno test -n sync
deno test tests/unitCode language: Bash (bash)

Test Organisation

I organise my tests to mirror my source structure:

app/
  services/
    ExcelService.ts
  repositories/
    BillboardRepository.ts
tests/
  unit/
    app/
      services/
        excel_service_test.ts
      repositories/
        billboard_repository_test.ts

This makes it easy to find the test for any file. Deno’s import maps work in tests too, so I can use the same path aliases:

import { ExcelService } from "app/services/ExcelService.ts";Code language: TypeScript (typescript)

Test Steps

Deno supports test steps, which are great for organising related assertions:

import { assertInstanceOf } from "jsr:@std/assert";

Deno.test("Testing Crux API client", async (t) => {
  await t.step("constructor initializes", () => {
    const crux = new Crux(mediaService);
    assertInstanceOf(crux, Crux);
  });

  await t.step("getAllMedia returns data", async () => {
    const media = await crux.getAllMedia();
    assertEquals(media.length, 10);
  });
});Code language: TypeScript (typescript)

Test steps make it clear what each part of your test is verifying. When a step fails, you know exactly which assertion broke.

Unit Testing Patterns

Unit tests should be fast and isolated. In Deno, this usually means mocking external dependencies and testing business logic in isolation.

Mocking with Deno’s Built‑in Tools

Deno includes mocking utilities in @std/testing/mock. No Sinon needed:

import { stub } from "jsr:@std/testing/mock";
import { assertEquals } from "jsr:@std/assert";

Deno.test("SyncJobService processes jobs", async () => {
  const mockRepository = {
    findById: () => Promise.resolve(mockJob),
    update: () => Promise.resolve(),
  };

  const findByIdStub = stub(
    mockRepository,
    "findById",
    () => Promise.resolve(mockJob),
  );

  const service = new SyncJobService(mockRepository);
  await service.processJob(1);

  assertEquals(findByIdStub.calls.length, 1);
  findByIdStub.restore();
});Code language: TypeScript (typescript)

The stub function lets you replace methods with test implementations and inspect how they were called. When you’re done, call restore() to clean up.

Async and Error Testing

Deno’s async model ends up feeling very natural to write in tests; most of my async tests look like this:

import { assertEquals } from "jsr:@std/assert";

Deno.test("async operation completes", async () => {
  const result = await someAsyncFunction();
  assertEquals(result, expected);
});Code language: TypeScript (typescript)

No done() callbacks, no Promise chains. Just await and assertions.

For error cases, Deno’s @std/assert includes assertRejects:

import { assertRejects } from "jsr:@std/assert";

Deno.test("throws error on invalid input", async () => {
  await assertRejects(
    () => service.doSomething(invalidInput),
    Error,
    "Expected error message",
  );
});Code language: TypeScript (typescript)

This lets you verify both the error type and message, which makes error testing explicit and type‑safe.

Test Factories

Here’s a pattern I’ve found helpful, but it’s an optional extra if you’re just getting started.

I use factories to create test data and in my app I also use the table definition to generate DB records in some tests. This keeps tests DRY and readable:

class Factory<T> {
  constructor(
    private defaultValues: T,
    private table?: unknown,
  ) {}

  make(overrides?: Partial<T>): T {
    return { ...this.defaultValues, ...overrides };
  }
}

const billboardFactory = new Factory(
  {
    id: 1,
    name: "Test Billboard",
    location: "Test Location",
  },
  billboardsTable,
);

const testBillboard = billboardFactory.make({ name: "Custom Name" });Code language: TypeScript (typescript)

Factories make test setup readable and reusable. You can override specific fields while keeping sensible defaults.

Integration Testing Strategies

Unit tests are fast, but integration tests catch the bugs that matter in production. I test against a real MySQL database using Docker Compose.

Setting Up Integration Tests

My integration test setup uses Docker Compose to provide an isolated test database:

# Reset test DB before tests
docker compose exec db mysql -e \
  "DROP DATABASE IF EXISTS test_db; CREATE DATABASE test_db;"

# Run Drizzle migrations
deno run -A npm:drizzle-kit migrate --config=drizzle.config.test.ts

# Run tests
deno test -A --env-file=.env.testCode language: Bash (bash)

Each test run starts with a clean database. This ensures tests don’t interfere with each other.

Testing Repositories

I test repositories against the real database:

import { assertEquals } from "jsr:@std/assert";
import { eq } from "drizzle-orm";

Deno.test("BillboardRepository finds by id", async () => {
  const repo = new DrizzleBillboardRepository(db);

  // Insert test data
  await db.insert(billboardsTable).values({
    id: 1,
    name: "Test Billboard",
  });

  const result = await repo.findById(1);

  assertEquals(result?.name, "Test Billboard");

  // Cleanup
  await db.delete(billboardsTable).where(eq(billboardsTable.id, 1));
});Code language: TypeScript (typescript)

This tests the actual database interaction, not a mock. It catches SQL errors, type mismatches, and connection issues.

Testing Services

For services, I usually mock repositories but still test business logic:

import { assertEquals } from "jsr:@std/assert";
import { stub } from "jsr:@std/testing/mock";

Deno.test("SyncJobService orchestrates sync", async () => {
  const repo = {
    findById: () => Promise.resolve(mockJob),
    update: () => Promise.resolve(),
  };

  const updateStub = stub(
    repo,
    "update",
    () => Promise.resolve(),
  );

  const service = new SyncJobService(repo);
  await service.processJob(1);

  // Verify business logic interacted with the repo as expected
  assertEquals(updateStub.calls.length, 1);
  updateStub.restore();
});Code language: TypeScript (typescript)

This isolates business logic from data access. You get fast unit tests that still verify the service does what it should.

Mocking External APIs

For external API clients, I use @c4spar/mock-fetch from JSR:

import { assertEquals } from "jsr:@std/assert";
import { mockFetch } from "jsr:@c4spar/mock-fetch";

Deno.test("Broadsign API client handles responses", async () => {
  mockFetch("https://api.broadsign.com/v1/billboards", {
    body: JSON.stringify(mockBillboards),
    status: 200,
  });

  const client = new BroadsignClient();
  const billboards = await client.getBillboards();

  assertEquals(billboards.length, 5);
});Code language: TypeScript (typescript)

This lets me test API clients without making real HTTP calls. It’s useful for testing error handling, retries, and response parsing.

Note: In some setups you’ll want to call  mockGlobalFetch()  so your test sees the mocked fetch globally.

Test Coverage: Built‑in Tooling

Deno’s built‑in coverage tooling is excellent. No external tools needed:

deno test --coverage=cov_profile
deno coverage cov_profile --htmlCode language: Bash (bash)

This generates an HTML report showing line‑by‑line coverage (typically under cov_profile/html). I use this in GitHub Actions to track coverage over time.

Coverage Goals

I aim for 85%+ coverage. This isn’t a magic number, but it’s roughly the point where I feel confident that most code paths are tested. Deno’s coverage tool makes it easy to find untested code:

deno coverage cov_profile --html
# Opens coverage report in browserCode language: Bash (bash)

The HTML report highlights untested lines. I use this to find gaps in my test suite.

Coverage in CI/CD

I run coverage in GitHub Actions on every PR:

- name: Run tests with coverage
  run: |
    deno test --coverage=cov_profile
    deno coverage cov_profile --html

- name: Upload coverage report
  uses: actions/upload-artifact@v3
  with:
    name: coverage-report
    path: cov_profile/htmlCode language: YAML (yaml)

This gives me coverage reports for every pull request. I can see what changed and whether coverage improved or declined.

Testing Best Practices

After a few months of testing in Deno, these are the practices that seem to work best for me.

1. Mirror Source Structure

Organise tests to match your source code. This makes it easy to find tests:

app/services/ExcelService.ts
→ tests/unit/app/services/excel_service_test.ts

Deno’s import maps work in tests, so you can use the same path aliases.

2. Test Against Real Databases

For integration tests, use a real database. Docker Compose makes this easy:

services:
  test_db:
    image: mysql/mysql-server:8.0
    environment:
      MYSQL_ROOT_PASSWORD: test_password
      MYSQL_DATABASE: test_dbCode language: Dockerfile (dockerfile)

This catches bugs that mocks miss. SQL errors, type mismatches, and connection issues only show up with real databases.

3. Mock at Boundaries

Mock external dependencies (APIs, third‑party services), but test business logic with real implementations:

  • Mock: External APIs, third‑party services
  • Real: Database (for integration tests), business logic

This gives you fast unit tests and thorough integration tests.

4. Use Test Factories

Factories make test data creation DRY and readable:

const billboard = billboardFactory.make({ name: "Custom" });Code language: TypeScript (typescript)

Better than repeating the same object structure in every test.

5. Clean Up After Tests

Always clean up test data:

Deno.test("creates billboard", async () => {
  const billboard = await createBillboard(testData);

  // Test assertions...

  // Cleanup
  await deleteBillboard(billboard.id);
});Code language: TypeScript (typescript)

This prevents tests from interfering with each other.

6. Test Error Cases

Don’t just test happy paths. Test error cases too:

import { assertRejects } from "jsr:@std/assert";

Deno.test("handles database errors", async () => {
  const mockRepo = {
    findById: () => Promise.reject(new Error("DB error")),
  };

  await assertRejects(
    () => service.processJob(1),
    Error,
    "DB error",
  );
});Code language: TypeScript (typescript)

Error cases are where bugs hide. Test them explicitly.

Real‑World Example: Testing a Sync Service

Here’s what all of this looks like when it comes together in a real service. This is how I test a sync service that processes billboard data:

Deno.test("SyncJobService processes batch", async () => {
  // Setup: Create test job in database
  const job = await createTestJob();

  // Mock external API
  mockFetch("https://api.broadsign.com/v1/billboards", {
    body: JSON.stringify(mockBillboards),
    status: 200,
  });

  // Create service with real repository, mocked API client
  const repo = new DrizzleSyncJobRepository(db);
  const apiClient = new MockBroadsignClient();
  const service = new SyncJobService(repo, apiClient);

  // Execute
  await service.processBatch(job.id, mockBillboards);

  // Verify: Check database state
  const updatedJob = await repo.findById(job.id);
  assertEquals(updatedJob?.status, "completed");

  // Verify: Check job items were created
  const items = await repo.findItemsByJobId(job.id);
  assertEquals(items.length, mockBillboards.length);

  // Cleanup
  await cleanupTestJob(job.id);
});Code language: TypeScript (typescript)

This test:

  • Uses a real database (catches SQL errors)
  • Mocks the external API (fast, no real calls)
  • Tests the full flow (catches integration bugs)
  • Cleans up after itself (doesn’t affect other tests)

Conclusion

Deno’s built‑in testing tools are powerful and simple. For my projects, I haven’t needed external testing frameworks or libraries; the runtime and standard library have been enough.

My testing approach:

  • Unit tests: Fast, isolated, mocking external dependencies
  • Integration tests: Against a real database, catching real bugs
  • Coverage: Built‑in tooling, tracked in CI/CD

After a few months in production, this combination of fast unit tests and thorough integration tests has caught bugs before they reached production and given me confidence in my code.

If you’re coming from Node.js, Deno’s testing tools will feel familiar but simpler. No heavy setup or extra dependencies. Just write tests and run them.