DEV Community

Yigit Konur
Yigit Konur

Posted on

The Comprehensive Deno Testing Guide

A Complete Reference for Unit Testing, Integration Testing, and Supabase Edge Functions


1. Introduction to Deno Testing

1.1 Overview of Deno's Built-In Test Runner

1.1.1 Core Philosophy and Design Principles

Deno includes a built-in test runner as part of its core runtime, eliminating the need to install external testing frameworks like Jest or Mocha. This design philosophy significantly reduces dependency management overhead and simplifies the development workflow. As noted in the official documentation, "Deno has a built-in test runner that you can use for testing JavaScript or TypeScript code."

The test runner follows a zero-configuration philosophy—it works immediately after Deno installation without requiring any setup files, configuration objects, or initialization commands. This approach dramatically simplifies onboarding for new developers who can begin writing and running tests within minutes of installing Deno.

Deno's test runner inherits the runtime's security-first design through a permission-based sandbox model. Tests run with restricted access by default, meaning they cannot access the file system, network, or environment variables unless explicitly granted permission. This security model ensures that tests can verify code behavior when permissions are denied, catching potential security issues early in the development cycle.

The test runner emphasizes web standard compliance, using standard APIs like fetch rather than proprietary modules. This alignment with web standards means testing strategies developed for Deno are transferable to browser environments and vice versa. When testing HTTP requests, you use the same fetch API that runs in production Edge Functions.

1.1.2 Comparison with External Test Frameworks (Jest, Mocha)

Deno's test runner shares key similarities with popular frameworks:

Feature Deno Test Jest Mocha
Assertion Functions Built-in via std library Built-in Requires Chai
Async Support Native async/await Native async/await Native async/await
Test Organization Deno.test() describe/it describe/it
TypeScript Support Native Requires babel/ts-jest Requires ts-node

Unique Deno advantages include:

  • Native TypeScript support without transpilation or additional configuration
  • Single binary approach—no node_modules directory or npm-based toolchains
  • Built-in permissions testing for security validation
  • URL-based imports with version pinning

Areas where external frameworks excel:

  • Richer plugin ecosystems with hundreds of extensions
  • More output format options (HTML reports, JUnit XML)
  • Snapshot testing with visual diff tools
  • Coverage reporting with Istanbul integration

Migration considerations: Existing Jest tests can be adapted to Deno with syntax translations:

// Jest syntax
describe('math', () => {
  it('adds numbers', () => {
    expect(add(2, 3)).toBe(5);
  });
});

// Deno equivalent
Deno.test('math: adds numbers', () => {
  assertEquals(add(2, 3), 5);
});
Enter fullscreen mode Exit fullscreen mode

1.1.3 TypeScript-First Architecture

Deno executes TypeScript natively without a separate compilation step. When you run deno test myfile_test.ts, Deno compiles and caches the TypeScript internally, providing immediate feedback without manual build configuration. Type checking integrates seamlessly with test runs, catching type errors before assertions execute.

Benefits for test code quality include:

  • Type safety in test assertions prevents comparing incompatible types
  • Interfaces define expected shapes for mock objects and test data
  • IDE autocompletion works out-of-the-box for test utilities
  • Refactoring propagates through test files automatically

Import syntax differs from Node.js:

// Deno: URL-based imports with version pinning
import { assertEquals } from 'jsr:@std/assert@1';
import { createClient } from 'npm:@supabase/supabase-js@2';

// Node.js: Package-based requires
const { assertEquals } = require('assert');
const { createClient } = require('@supabase/supabase-js');
Enter fullscreen mode Exit fullscreen mode

1.2 Test Runner Features and Capabilities

1.2.1 Native Feature Set

1.2.1.1 Parallel Execution Support

The --jobs parameter controls worker count for parallel test execution. As documented in the source materials, "The test runner has an option to run multiple test files (also called suites) in parallel using test workers." Importantly, parallelism operates at the suite level (file level), not individual test level.

deno test --jobs 3   # Run up to 3 test files simultaneously
Enter fullscreen mode Exit fullscreen mode

Performance improvements are substantial. From the source documentation:

"A single run of the above suite takes around 9.5 seconds... A parallel execution of a_test.ts and a2_test.ts takes around 9.7 seconds. It takes almost half the time! This would significantly reduce the CI/CD time when there is a big test suite to execute."

Worker allocation behavior: When you specify --jobs N, Deno spawns N workers. Test files are queued and assigned to workers as they become free. The optimal job count typically matches your CPU core count, though I/O-bound tests may benefit from higher values.

1.2.1.2 Recursive Test Discovery

Deno's test runner recursively finds and runs all tests present in a given directory. The file pattern matching rules follow this specification:

{*_,*.,}test.{js,mjs,ts,jsx,tsx}
Enter fullscreen mode Exit fullscreen mode

Valid test file name examples:

  • user_test.ts (underscore separator)
  • user.test.ts (dot separator)
  • test.ts (standalone)
  • api_test.js, component.test.tsx

Directory traversal automatically scans all subdirectories when you provide a directory path:

deno test myApp/          # Scans myApp/ and all subdirectories
deno test                 # Scans current directory recursively
Enter fullscreen mode Exit fullscreen mode

If no test files are found, Deno prints: No matching test modules found

1.2.1.3 Permission-Based Sandboxing

Relevant permission flags:

Flag Controls Example
--allow-net Network requests via fetch --allow-net=localhost:54321
--allow-env Environment variable access --allow-env=SUPABASE_URL
--allow-read File system reads --allow-read=./fixtures
--allow-write File system writes --allow-write=./output
--allow-all All permissions Development convenience

Security implications: Tests can verify that code behaves correctly when permissions are denied. For example, testing that an unauthorized network request throws an appropriate error rather than silently failing.

Production parity guidance: CI tests should use the same permission set as production deployments. Avoid over-using --allow-all in CI pipelines to ensure tests catch permission-related bugs.

1.2.2 Current Limitations

1.2.2.1 Console-Only Output

The test runner outputs results only to stdout/stderr—there is no native support for file-based reports or alternative output channels. From the source documentation:

"The output and result of the test is sent only to console. This poses an issue when running tests through scripts (like CI/CD)."

Workarounds include:

  • Capturing output via child processes using Deno.run()
  • Piping stdout to files: deno test > results.txt
  • Building custom result processors that parse console output

1.2.2.2 Text-Based Results Format

Deno's test runner produces only textual output with no native JSON format. This creates challenges for automation:

"The test runner produces only a textual output. There is no support for JSON. A JSON formatted output would have been useful to analyze later and/or generate stats."

Parsing requires regex patterns to extract pass/fail counts:

const pattern = /test\sresult:\s(.*)(\d+)\spassed;\s(\d+)\sfailed;\s(\d+)\signored;.*/gm;
Enter fullscreen mode Exit fullscreen mode

Community libraries and custom utilities can convert text output to structured JSON for CI/CD integration (covered in Section 9).


1.3 When to Use Deno's Test Runner

1.3.1 Unit Testing Use Cases

Appropriate unit testing scenarios:

  • Testing pure functions with no side effects
  • Validating business logic in isolation
  • Testing data transformations and calculations
  • Verifying error handling for invalid inputs

The import pattern for unit tests involves importing functions directly from source modules:

import { getLongUuid } from "../lib/a/a.ts";
import { assert, assertThrows } from "jsr:@std/assert@1";

Deno.test('len is 1', () => {
  assert(getLongUuid(1).length === 36);
});

Deno.test('len is 0 throws error', () => {
  assertThrows(() => getLongUuid(0), Deno.errors.InvalidData);
});
Enter fullscreen mode Exit fullscreen mode

Coverage expectations: Unit tests form the pyramid's base. Aim for high function coverage, with one test file per source module (e.g., src/a.tstest/a_test.ts).

1.3.2 Integration Testing Use Cases

Integration testing scope covers testing interactions between components—verifying that modules work correctly together. As noted in the documentation:

"Deno's test runner is flexible enough to do integration testing too... The test runner is flexible enough to be used beyond simple unit testing."

The service-under-test model involves running your application as a service and testing it through HTTP requests (blackbox testing):

Deno.test("Integration: GET /data", async () => {
  const res = await fetch('http://localhost:5000/data');
  assert(res.ok === true);
  assert(res.status === 200);
});
Enter fullscreen mode Exit fullscreen mode

Marking and separation strategies use environment variables:

Deno.test({
  name: "integration test",
  ignore: !Deno.env.get('TEST_TYPE_INTEG'),  // Skip unless integration mode
  fn: async () => { /* test code */ }
});
Enter fullscreen mode Exit fullscreen mode

1.3.3 Edge Function Testing Use Cases

Edge Function specific requirements include:

  • Supabase client integration for database and function invocation
  • Testing against the local Supabase stack (supabase start)
  • Environment configuration for SUPABASE_URL and keys

CORS testing necessity: Edge Functions must handle preflight OPTIONS requests:

if (req.method === 'OPTIONS') {
  return new Response('ok', { headers: corsHeaders });
}
Enter fullscreen mode Exit fullscreen mode

Function invocation testing uses the Supabase client:

const { data, error } = await client.functions.invoke('hello-world', {
  body: { name: 'TestUser' }
});
assertEquals(data.message, 'Hello TestUser!');
Enter fullscreen mode Exit fullscreen mode

2. Environment Setup and Configuration

2.1 Installing Prerequisites

2.1.1 Deno Runtime Installation

Platform-specific installation commands:

# macOS/Linux (official installer)
curl -fsSL https://deno.land/install.sh | sh

# macOS (Homebrew)
brew install deno

# Windows (PowerShell)
irm https://deno.land/install.ps1 | iex

# Windows (Chocolatey)
choco install deno
Enter fullscreen mode Exit fullscreen mode

Version management:

# Check installed version
deno --version

# Upgrade to latest
deno upgrade

# Upgrade to specific version
deno upgrade --version 1.40.0
Enter fullscreen mode Exit fullscreen mode

Verify successful installation by running:

deno eval "console.log('Deno is working!')"
Enter fullscreen mode Exit fullscreen mode

2.1.2 Docker Installation for Supabase Local Stack

Docker's role in local development: Supabase relies on Docker for running PostgreSQL and other services locally. From the documentation:

"Install Docker: Supabase relies on Docker for local development."

Installation guides:

Resource requirements:

  • Minimum 4GB RAM allocated to Docker
  • Recommended 8GB+ for development with multiple services
  • 2+ CPU cores for reasonable performance

Configure Docker Desktop settings under Preferences → Resources.

2.1.3 Supabase CLI Installation

npm installation command:

npm install -g supabase
Enter fullscreen mode Exit fullscreen mode

Initialize a Supabase project:

supabase init
Enter fullscreen mode Exit fullscreen mode

This creates the necessary configuration files including supabase/config.toml.

Start the local stack:

supabase start
Enter fullscreen mode Exit fullscreen mode

This launches Docker containers for:

  • PostgreSQL database
  • GoTrue (authentication)
  • PostgREST (auto-generated API)
  • Realtime (websocket server)
  • Storage API
  • Edge Runtime

2.2 Project Structure and Organization

2.2.1 Recommended Folder Hierarchy

2.2.1.1 Source Code Placement

The standard structure from Supabase documentation:

└── supabase
    ├── functions
    │   ├── function-one
    │   │   └── index.ts
    │   └── function-two
    │       └── index.ts
    └── config.toml
Enter fullscreen mode Exit fullscreen mode

Naming conventions: Use hyphens for function names (e.g., hello-world). The function name becomes the URL endpoint: https://your-project.supabase.co/functions/v1/hello-world.

Shared code organization: Create a _shared directory for common utilities:

└── supabase
    └── functions
        ├── _shared
        │   ├── cors.ts
        │   └── supabase-client.ts
        ├── function-one
        │   └── index.ts
Enter fullscreen mode Exit fullscreen mode

Import using relative paths: import { corsHeaders } from "../_shared/cors.ts".

2.2.1.2 Test File Placement Strategies

The separate tests directory approach (recommended):

└── supabase
    ├── functions
    │   ├── function-one
    │   │   └── index.ts
    │   └── tests
    │       └── function-one-test.ts  # Tests for function-one
    └── config.toml
Enter fullscreen mode Exit fullscreen mode

The co-located approach places test files alongside source:

└── supabase
    └── functions
        └── function-one
            ├── index.ts
            └── index_test.ts
Enter fullscreen mode Exit fullscreen mode

Recommendation: Separate directories are preferred for larger projects as they clearly distinguish production code from test code. Co-location works well for small projects or single-function repositories.

2.2.1.3 Shared Utilities and Constants

Create a constants file to reduce magic numbers:

// tests/constants.ts
export const SIZE_OF_SINGLE_UUID = 36;
export const TEST_TIMEOUT_MS = 5000;
export const LOCAL_SUPABASE_URL = "http://localhost:54321";
Enter fullscreen mode Exit fullscreen mode

Build test helper functions:

// tests/helpers.ts
export function createTestUser() {
  return { id: crypto.randomUUID(), email: `test-${Date.now()}@example.com` };
}
Enter fullscreen mode Exit fullscreen mode

Organize mock data in fixture files:

// tests/fixtures/users.json
[
  { "id": "user-1", "name": "Test User", "role": "admin" }
]
Enter fullscreen mode Exit fullscreen mode

2.2.2 Test File Naming Conventions

2.2.2.1 Pattern Matching Rules

All valid patterns:

  • *_test.tsuser_test.ts, api_test.ts
  • *.test.tsuser.test.ts, api.test.ts
  • test.ts → Standalone test file

Examples of valid test file names:

  • function-one-test.ts
  • database.test.ts
  • auth_test.ts

Pattern matching order: Deno scans recursively and collects all files matching any valid pattern.

2.2.2.2 File Extension Requirements

Supported extensions: .ts, .js, .tsx, .jsx, .mjs

Recommendation: Use .ts extensions for type safety:

// user_test.ts - TypeScript provides better IDE support
import { UserService } from "../services/user.ts";
import type { User } from "../types.ts";
Enter fullscreen mode Exit fullscreen mode

Use .tsx for testing React/Preact components that render JSX.


2.3 Environment Variables Management

2.3.1 Creating and Loading .env Files

2.3.1.1 Local Development Configuration

Create the .env file:

# Create the file
touch .env.local

# Add Supabase variables
echo "SUPABASE_URL=http://localhost:54321" >> .env.local
echo "SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" >> .env.local
Enter fullscreen mode Exit fullscreen mode

Load variables in test files:

// Import at the top of your test file
import 'jsr:@std/dotenv/load';

// Now Deno.env is populated
const supabaseUrl = Deno.env.get('SUPABASE_URL');
Enter fullscreen mode Exit fullscreen mode

Secure the file: Add .env.local to .gitignore to prevent committing secrets:

# .gitignore
.env
.env.local
.env.*.local
Enter fullscreen mode Exit fullscreen mode

2.3.1.2 Test-Specific Environment Files

Create separate environment files for different contexts:

# .env.test - Test-specific configuration
SUPABASE_URL=http://localhost:54321
TEST_TYPE_INTEG=1
LOG_LEVEL=debug
Enter fullscreen mode Exit fullscreen mode

Use the --env-file flag:

deno test --env-file=.env.test --allow-all supabase/functions/tests/
Enter fullscreen mode Exit fullscreen mode

This approach differs from importing dotenv—the variables are available before module loading.

2.3.2 Accessing Environment Variables in Tests

Use Deno.env.get():

const supabaseUrl = Deno.env.get('SUPABASE_URL');  // Returns string | undefined
Enter fullscreen mode Exit fullscreen mode

Validate required variables:

const supabaseUrl = Deno.env.get('SUPABASE_URL');
const supabaseKey = Deno.env.get('SUPABASE_ANON_KEY');

if (!supabaseUrl) throw new Error('SUPABASE_URL is required.');
if (!supabaseKey) throw new Error('SUPABASE_ANON_KEY is required.');
Enter fullscreen mode Exit fullscreen mode

Handle optional variables with nullish coalescing:

const logLevel = Deno.env.get('LOG_LEVEL') ?? 'info';
const timeout = Number(Deno.env.get('TIMEOUT_MS') ?? '5000');
Enter fullscreen mode Exit fullscreen mode

2.3.3 Secret Management for CI/CD

Never commit secrets to version control:

# .gitignore
.env*
!.env.example
Enter fullscreen mode Exit fullscreen mode

Use GitHub Secrets for CI:

# .github/workflows/test.yml
- name: Run Tests
  run: deno test --allow-all
  env:
    SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
    SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
Enter fullscreen mode Exit fullscreen mode

Protect sensitive output: Ensure test runners don't log secrets in assertion failures or debug output.


2.4 Editor Configuration (VS Code)

2.4.1 Deno Extension Setup

Install the official Deno extension (ID: denoland.vscode-deno). The extension provides:

  • TypeScript autocompletion
  • Real-time type checking
  • Import suggestions from deno.land and JSR

Enable Deno for specific folders in .vscode/settings.json:

{
  "deno.enable": true,
  "deno.lint": true,
  "deno.unstable": true
}
Enter fullscreen mode Exit fullscreen mode

2.4.2 Multi-Root Workspace Configuration

The Node.js/Deno conflict: Projects often have both frontend (Node.js) and Edge Functions (Deno), causing extension conflicts. TypeScript intellisense may break in one environment.

Create a multi-root workspace by adding separate folders with path-specific settings:

{
  "deno.enable": true,
  "deno.enablePaths": ["./supabase/functions"]
}
Enter fullscreen mode Exit fullscreen mode

2.4.3 Path-Specific Settings

Create .vscode/settings.json in your project root:

{
  "deno.enable": false,
  "deno.enablePaths": ["supabase/functions"],
  "[typescript]": {
    "editor.defaultFormatter": "denoland.vscode-deno"
  }
}
Enter fullscreen mode Exit fullscreen mode

This enables Deno only for the functions path while preserving Node.js support elsewhere.


3. Test Runner Fundamentals

3.1 Running Tests

3.1.1 Basic Test Execution Commands

3.1.1.1 Running All Tests in Directory

Basic command:

deno test myApp/
Enter fullscreen mode Exit fullscreen mode

This recursively scans myApp/ and all subdirectories for test files matching the pattern {*_,*.,}test.{js,mjs,ts,jsx,tsx}.

Output format:

running 5 tests from file:///Users/.../test/a_test.ts
test len is 0 ... ok (5ms)
test len is 1 ... ok (3ms)
test len is 3 ... ok (1ms)
...
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (93ms)
Enter fullscreen mode Exit fullscreen mode

Summary interpretation:

  • passed: Tests that completed without throwing exceptions
  • failed: Tests that threw exceptions
  • ignored: Tests skipped via ignore: true
  • measured: Benchmark tests (using Deno.bench)
  • filtered out: Tests excluded by --filter

3.1.1.2 Running Specific Test Files

Target a single file:

deno test myApp/test/a_test.ts
Enter fullscreen mode Exit fullscreen mode

Target multiple specific files:

deno test a_test.ts b_test.ts c_test.ts
Enter fullscreen mode Exit fullscreen mode

Combine with permission flags:

deno test --allow-net --allow-env myApp/test/api_test.ts
Enter fullscreen mode Exit fullscreen mode

3.1.1.3 Running Tests in Current Directory

Simple command without arguments:

deno test
Enter fullscreen mode Exit fullscreen mode

This scans the current directory recursively. If no test files exist:

No matching test modules found
Enter fullscreen mode Exit fullscreen mode

Troubleshooting: Verify file names match the pattern (e.g., user_test.ts not usertest.ts).

3.1.2 Permission Flags

3.1.2.1 Network Permissions (--allow-net)

When network permission is needed:

  • HTTP requests via fetch()
  • Supabase client communication
  • Any external API calls

Grant full network access:

deno test --allow-net
Enter fullscreen mode Exit fullscreen mode

Restrict to specific hosts:

deno test --allow-net=localhost:54321,api.stripe.com
Enter fullscreen mode Exit fullscreen mode

3.1.2.2 Environment Permissions (--allow-env)

When environment permission is needed:

  • Accessing Deno.env.get()
  • Reading configuration from environment

Grant full access:

deno test --allow-env
Enter fullscreen mode Exit fullscreen mode

Restrict to specific variables:

deno test --allow-env=SUPABASE_URL,SUPABASE_KEY
Enter fullscreen mode Exit fullscreen mode

3.1.2.3 File System Permissions (--allow-read, --allow-write)

Read permission requirements:

  • Loading configuration files
  • Reading fixture data
  • Accessing seed files

Write permission requirements:

  • Writing test artifacts
  • Creating log files
  • Generating reports

Use path restrictions:

deno test --allow-read=./fixtures --allow-write=./output
Enter fullscreen mode Exit fullscreen mode

3.1.2.4 Using --allow-all for Development

The convenience flag grants all permissions:

deno test --allow-all
Enter fullscreen mode Exit fullscreen mode

Warning for CI usage: Production CI should use specific permissions to catch security issues:

# CI pipeline - explicit permissions
deno test --allow-net=localhost:54321 --allow-env --allow-read=./fixtures
Enter fullscreen mode Exit fullscreen mode

3.2 Test Runner Phases

3.2.1 Registration Phase

3.2.1.1 Directory Scanning Process

Recursive scanning: Deno traverses all subdirectories under the given path, collecting files in alphabetical order by default.

File matching: Only files matching the test pattern are processed:

{*_,*.,}test.{js,mjs,ts,jsx,tsx}
Enter fullscreen mode Exit fullscreen mode

Error handling: If any test case fails parsing (syntax error), the test runner doesn't proceed to execution phase.

3.2.1.2 Test File Pattern Matching

Pattern breakdown:

  • *_test → Any characters followed by underscore, then "test"
  • *.test → Any characters followed by dot, then "test"
  • test → Just "test" (no prefix required)
  • .{js,mjs,ts,jsx,tsx} → Any of these extensions

Positive examples: user_test.ts, api.test.js, test.ts

Negative examples: usertest.ts (missing separator), test_user.ts (wrong order)

3.2.1.3 Test Case Collection

Each Deno.test() call adds a test to the pending list:

Deno.test('test 1', () => { /* collected */ });
Deno.test('test 2', () => { /* collected */ });
Enter fullscreen mode Exit fullscreen mode

Validation: Test functions must be valid TypeScript/JavaScript. Compile errors abort registration.

Naming requirements: Test names should be unique strings. Duplicate names may cause confusion in output.

3.2.2 Execution Phase

3.2.2.1 Sequential vs Parallel Execution

Default sequential behavior: Without --jobs, tests run one-by-one in the order they were collected.

Parallel activation:

deno test --jobs 4   # 4 concurrent workers
Enter fullscreen mode Exit fullscreen mode

From the documentation:

"The test runner doesn't go inside a file and run the tests cases in parallel. Instead, it runs the suites in parallel."

Execution time comparison (from source materials):

  • Sequential (2 files): ~19 seconds
  • Parallel --jobs 2: ~9.7 seconds (50% reduction)

3.2.2.2 Error Recording and Continuation

Default continuation behavior: Failures are recorded but execution continues:

test len is 0 ... FAILED (2ms)
test len is 1 ... ok (3ms)
test len is 3 ... ok (1ms)
Enter fullscreen mode Exit fullscreen mode

Fail-fast mode:

deno test --fail-fast
Enter fullscreen mode Exit fullscreen mode

Stops at first failure with immediate error output.

3.2.2.3 Result Reporting

Per-test output format:

test NAME ... ok (Xms)
test NAME ... FAILED (Xms)
test NAME ... ignored (0ms)
Enter fullscreen mode Exit fullscreen mode

Summary line:

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (93ms)
Enter fullscreen mode Exit fullscreen mode

Exit codes: 0 for success, non-zero for failure—enabling CI integration.


3.3 Test Definition Syntax

3.3.1 Simple Test Definition

Basic two-argument syntax:

Deno.test('test name', () => {
  // Test code here
});
Enter fullscreen mode Exit fullscreen mode

Simple example:

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

function add(a: number, b: number): number {
  return a + b;
}

Deno.test('addition works', () => {
  const result = add(2, 3);
  assertEquals(result, 5);
});
Enter fullscreen mode Exit fullscreen mode

Pass/fail criteria: The test passes if no exception is thrown.

3.3.2 Structured Test Definition Object

Object syntax with options:

Deno.test({
  name: 'integration test',
  ignore: !Deno.env.get('RUN_INTEGRATION'),
  fn: async () => {
    // test code
  }
});
Enter fullscreen mode Exit fullscreen mode

Available options:

Option Type Purpose
name string Test name for output
fn function Test function to execute
ignore boolean Skip test if true
only boolean Run only this test
sanitizeOps boolean Check for async operation leaks
sanitizeResources boolean Check for resource leaks
sanitizeExit boolean Check for Deno.exit() calls

3.3.3 Async Test Functions

Async test support:

Deno.test('async operation', async () => {
  const result = await fetchData();
  assertExists(result);
});
Enter fullscreen mode Exit fullscreen mode

Deno automatically awaits async test functions. Long-running tests complete normally without special configuration.


3.4 Understanding Test Results

3.4.1 Pass Criteria (No Exception)

The fundamental rule:

"The test is considered passed if the test function finishes without raising any exception. Pass = No exception."

Assertion behavior: All assertion functions throw AssertionError on failure:

assertEquals(1, 2);  // Throws AssertionError: Values are not equal
Enter fullscreen mode Exit fullscreen mode

Implicit passing: A test that returns without throwing succeeds, even if empty:

Deno.test('empty test', () => {});  // Passes
Enter fullscreen mode Exit fullscreen mode

3.4.2 Failure Handling

Failure output includes stack traces:

failures:

len is 0
InvalidData: len must be between 1 and 5
    at getLongUuid (file:///Users/.../a.ts:5:15)
    at file:///Users/.../a_test.ts:4:5
Enter fullscreen mode Exit fullscreen mode

assertEquals diff output shows color-coded differences:

AssertionError: Values are not equal:
[Diff] Actual / Expected
- 2021-07-01T07:00:00.000Z
+ 2021-07-02T07:00:00.000Z
Enter fullscreen mode Exit fullscreen mode

3.4.3 Output Interpretation

Parse the summary line:

test result: ok. 5 passed; 1 failed; 2 ignored; 0 measured; 3 filtered out (4124ms)
Enter fullscreen mode Exit fullscreen mode
  • passed (5): Tests that succeeded
  • failed (1): Tests that threw exceptions
  • ignored (2): Tests skipped via ignore: true
  • filtered out (3): Tests excluded by --filter flag

Timing information: Per-test timing (in milliseconds) and total suite time help identify slow tests.


4. Writing Effective Tests

4.1 Test Case Structure

4.1.1 Arrange-Act-Assert Pattern

The AAA structure:

  1. Arrange: Set up test data and preconditions
  2. Act: Execute the code under test
  3. Assert: Verify the results

Practical example:

Deno.test('user creation', async () => {
  // Arrange
  const userData = { name: 'Test User', email: 'test@example.com' };

  // Act
  const user = await createUser(userData);

  // Assert
  assertExists(user.id);
  assertEquals(user.name, 'Test User');
});
Enter fullscreen mode Exit fullscreen mode

Recommendation: Use comments or blank lines to clearly separate phases for improved readability.

4.1.2 Test Naming Conventions

Use descriptive names that describe the scenario and expected outcome:

// Good: Describes scenario and outcome
Deno.test('getLongUuid: throws error when length is 0', ...);
Deno.test('user service: returns null for non-existent user', ...);

// Bad: Generic and unhelpful
Deno.test('test1', ...);
Deno.test('works', ...);
Enter fullscreen mode Exit fullscreen mode

Consistent patterns:

  • "function name: scenario""getLongUuid: len is 100000"
  • "should do X when Y""should throw error when input is empty"

4.1.3 Test Isolation Principles

Ensure tests are independent:

// Bad: Tests share state
let counter = 0;
Deno.test('increment', () => { counter++; assertEquals(counter, 1); });
Deno.test('increment again', () => { counter++; assertEquals(counter, 2); });  // Brittle!

// Good: Each test creates its own state
Deno.test('increment', () => {
  let counter = 0;
  counter++;
  assertEquals(counter, 1);
});
Enter fullscreen mode Exit fullscreen mode

Avoid ordering dependencies: Tests may run in any order (especially with --jobs).

Clean up after tests:

Deno.test('database test', async () => {
  const userId = await createTestUser();
  try {
    // Test code
  } finally {
    await deleteTestUser(userId);  // Cleanup
  }
});
Enter fullscreen mode Exit fullscreen mode

4.2 Positive Test Cases

4.2.1 Testing Expected Outputs

Verify return values with assertEquals:

Deno.test('addition returns correct sum', () => {
  const result = add(2, 3);
  assertEquals(result, 5);
});
Enter fullscreen mode Exit fullscreen mode

Check data structures with assertObjectMatch:

Deno.test('user has expected properties', () => {
  const user = getUser(1);
  assertObjectMatch(user, { id: 1, role: 'admin' });
  // Extra properties (like 'email') are allowed
});
Enter fullscreen mode Exit fullscreen mode

4.2.2 Validating Return Values

Test primitive returns:

assertEquals(getStatus(), 'active');      // string
assertEquals(getCount(), 42);             // number
assertEquals(isEnabled(), true);          // boolean
Enter fullscreen mode Exit fullscreen mode

Test object returns:

const result = getData();
assertExists(result.a);
assertExists(result.a.c.d);
assertStringIncludes(result.a.c.d, "deno");
Enter fullscreen mode Exit fullscreen mode

Test array returns:

const items = getItems();
assertEquals(items.length, 3);
assertArrayIncludes(items, ['apple', 'banana']);
Enter fullscreen mode Exit fullscreen mode

4.2.3 Checking State Changes

Verify database mutations:

Deno.test('creates user in database', async () => {
  await createUser({ name: 'Test' });

  const { data } = await supabase.from('users').select().eq('name', 'Test');
  assertEquals(data.length, 1);
});
Enter fullscreen mode Exit fullscreen mode

Verify API calls with spies (covered in Section 10).


4.3 Negative Test Cases

4.3.1 Testing Error Conditions

Use assertThrows for sync errors:

import { assertThrows } from 'jsr:@std/assert@1';

Deno.test('throws on invalid input', () => {
  assertThrows(
    () => validateInput(-1),
    Deno.errors.InvalidData,
    'must be positive'
  );
});
Enter fullscreen mode Exit fullscreen mode

Use assertThrowsAsync for async errors:

import { assertThrowsAsync } from 'jsr:@std/assert@1';

Deno.test('async function throws', async () => {
  await assertThrowsAsync(
    async () => await fetchUser('invalid-id'),
    Error,
    'User not found'
  );
});
Enter fullscreen mode Exit fullscreen mode

4.3.2 Boundary Value Testing

Test edge cases:

// From source documentation - testing UUID length boundaries
Deno.test('len is 0 throws', () => {
  assertThrows(() => getLongUuid(0), Deno.errors.InvalidData);
});

Deno.test('len is 6 throws (max is 5)', () => {
  assertThrows(() => getLongUuid(6), Deno.errors.InvalidData);
});

Deno.test('len is 1 (minimum valid)', () => {
  assert(getLongUuid(1).length === 36);  // Passes
});

Deno.test('len is 5 (maximum valid)', () => {
  assert(getLongUuid(5).length === 36 * 5 + 4);  // Passes
});
Enter fullscreen mode Exit fullscreen mode

4.3.3 Invalid Input Handling

Test null and undefined:

Deno.test('handles null input', () => {
  assertThrows(() => processData(null), TypeError);
});
Enter fullscreen mode Exit fullscreen mode

Test wrong types:

Deno.test('rejects string where number expected', () => {
  assertThrows(() => calculate('not a number'), TypeError);
});
Enter fullscreen mode Exit fullscreen mode

4.4 Test Suites Organization

4.4.1 Grouping Related Tests

Use Deno.test steps for sub-tests:

Deno.test('User CRUD operations', async (t) => {
  let userId: string;

  await t.step('Create user', async () => {
    userId = await createUser({ name: 'Test' });
    assertExists(userId);
  });

  await t.step('Read user', async () => {
    const user = await getUser(userId);
    assertEquals(user.name, 'Test');
  });

  await t.step('Delete user', async () => {
    await deleteUser(userId);
    const user = await getUser(userId);
    assertEquals(user, null);
  });
});
Enter fullscreen mode Exit fullscreen mode

4.4.2 One Test File Per Source Module

Match test files to source files:

src/
├── user.ts        →  test/user_test.ts
├── auth.ts        →  test/auth_test.ts
└── database.ts    →  test/database_test.ts
Enter fullscreen mode Exit fullscreen mode

Import the module under test:

// test/user_test.ts
import { createUser, getUser, deleteUser } from '../src/user.ts';
Enter fullscreen mode Exit fullscreen mode

4.4.3 Shared Setup and Teardown

Create setup helper functions:

// test/helpers.ts
export async function setupTestUser() {
  return await supabase.auth.admin.createUser({
    email: `test-${crypto.randomUUID()}@example.com`,
    email_confirm: true
  });
}
Enter fullscreen mode Exit fullscreen mode

Use try/finally for teardown:

Deno.test('user operations', async () => {
  const { user } = await setupTestUser();
  try {
    // Test code
  } finally {
    await supabase.auth.admin.deleteUser(user.id);
  }
});
Enter fullscreen mode Exit fullscreen mode

5. Assertions Library Deep Dive

5.1 Importing Assertions

5.1.1 Individual Import Strategy

Import specific assertions:

import { assert, assertEquals, assertThrows } from 'jsr:@std/assert@1';
Enter fullscreen mode Exit fullscreen mode

Benefits: Clear documentation of which assertions are used; smaller bundle conceptually.

Commonly used assertions:

  • assert - Verify truthy values
  • assertEquals - Deep equality comparison
  • assertThrows - Verify exceptions
  • assertExists - Check not null/undefined

5.1.2 Namespace Import Strategy

Import all assertions:

import * as asserts from 'jsr:@std/assert@1';

// Use with namespace prefix
asserts.assertEquals(result, expected);
asserts.assertExists(user);
Enter fullscreen mode Exit fullscreen mode

Use qualified names for clarity when many assertions are used.

5.1.3 Version Pinning Best Practices

Pin to specific versions for reproducibility:

import { assertEquals } from 'jsr:@std/assert@1.0.0';  // Exact version
import { assertEquals } from 'jsr:@std/assert@1';      // Latest 1.x
Enter fullscreen mode Exit fullscreen mode

Update intentionally: Review changelogs before upgrading assertion library versions.


5.2 Basic Assertions

5.2.1 assert() Function

5.2.1.1 Expression Evaluation

Signature: assert(expr: unknown, msg?: string)

Pass on truthy values: Non-null, non-zero, non-empty values pass:

assert(1);           // Passes
assert("hello");     // Passes
assert([1, 2, 3]);   // Passes
assert({});          // Passes (empty object is truthy)
Enter fullscreen mode Exit fullscreen mode

Fail on falsy values: false, 0, '', null, undefined fail:

assert(0);           // AssertionError
assert("");          // AssertionError
assert(null);        // AssertionError
Enter fullscreen mode Exit fullscreen mode

5.2.1.2 Custom Error Messages

Provide meaningful messages:

assert(count === expected, `Expected ${expected} but got ${count}`);
Enter fullscreen mode Exit fullscreen mode

Default message: Omitting the second parameter uses an empty error message.

5.2.2 assertEquals() Function

5.2.2.1 Deep Comparison Behavior

Recursive comparison: Objects and arrays are compared deeply:

assertEquals({ a: { b: 1 } }, { a: { b: 1 } });  // Passes
assertEquals([1, [2, 3]], [1, [2, 3]]);          // Passes
Enter fullscreen mode Exit fullscreen mode

Compare by value, not reference: Two identical objects pass even if different instances.

5.2.2.2 Type Flexibility

Accept any types:

assertEquals(10, 10);                          // number
assertEquals("abc", "abc");                    // string
assertEquals(true, true);                      // boolean
assertEquals({ a: 10 }, { a: 10 });           // object
assertEquals(new Date(2021, 6, 1), new Date(2021, 6, 1));  // Date
Enter fullscreen mode Exit fullscreen mode

5.2.2.3 Pretty Diff Output

Diff format on failure:

AssertionError: Values are not equal:
[Diff] Actual / Expected
- 2021-07-01T07:00:00.000Z
+ 2021-07-02T07:00:00.000Z
Enter fullscreen mode Exit fullscreen mode

The - prefix indicates actual value; + indicates expected value.

5.2.3 assertNotEquals() Function

Inverse behavior: Passes when values differ, fails when they match:

assertNotEquals({ a: 10 }, { a: 11 });  // Passes
assertNotEquals(10, 10);                 // AssertionError
Enter fullscreen mode Exit fullscreen mode

Note: Does not produce pretty diff output.


5.3 Strict Equality Assertions

5.3.1 assertStrictEquals() Function

5.3.1.1 Reference Checking

Reference equality: Objects must be the same instance:

const xs = [1, 2, 3];
const ys = xs;          // Same reference
const zs = [1, 2, 3];   // Different reference, same content

assertStrictEquals(xs, ys);   // Passes - same reference
assertStrictEquals(xs, zs);   // Fails - different references
Enter fullscreen mode Exit fullscreen mode

Error message:

AssertionError: Values have the same structure but are not reference-equal
Enter fullscreen mode Exit fullscreen mode

5.3.1.2 Primitive Type Behavior

Primitives use value comparison (equivalent to ===):

assertStrictEquals(10, 10);     // Passes
assertStrictEquals(1, '1');     // Fails - different types
assertStrictEquals(true, true); // Passes
Enter fullscreen mode Exit fullscreen mode

5.3.2 assertNotStrictEquals() Function

Inverse: Passes when references differ:

const x = { a: 1 };
const y = { a: 1 };  // Same structure, different reference
const z = x;         // Same reference

assertNotStrictEquals(x, y);  // Passes - different references
assertNotStrictEquals(x, z);  // Fails - same reference
Enter fullscreen mode Exit fullscreen mode

5.4 Existence and Type Assertions

5.4.1 assertExists() Function

5.4.1.1 Null Check

Rejects null:

assertExists(null);  // AssertionError: Expected actual to exist
Enter fullscreen mode Exit fullscreen mode

5.4.1.2 Undefined Check

Rejects undefined:

let x: string | undefined;
assertExists(x);  // AssertionError - x is undefined
Enter fullscreen mode Exit fullscreen mode

Distinguish from falsy: 0, '', and false pass (they exist):

assertExists(0);      // Passes
assertExists('');     // Passes
assertExists(false);  // Passes
Enter fullscreen mode Exit fullscreen mode

5.5 String Assertions

5.5.1 assertStringIncludes() Function

Substring checking:

assertStringIncludes("Welcome to Deno!", "Deno");   // Passes
assertStringIncludes("Hello", "world");              // Fails
Enter fullscreen mode Exit fullscreen mode

Use for partial matching: Validate error messages or generated content.

5.5.2 assertMatch() Function (Regex)

Regex testing:

assertMatch("user-123", /user-\d+/);     // Passes
assertMatch("test@email.com", /.*@.*/);  // Passes
assertMatch("no numbers", /\d+/);        // Fails
Enter fullscreen mode Exit fullscreen mode

Use for format validation: UUIDs, emails, dates, etc.

5.5.3 assertNotMatch() Function

Inverse behavior: Passes when regex doesn't match:

assertNotMatch("hello world", /\d+/);  // Passes - no digits
assertNotMatch("hello 123", /\d+/);    // Fails - contains digits
Enter fullscreen mode Exit fullscreen mode

5.6 Collection Assertions

5.6.1 assertArrayIncludes() Function

5.6.1.1 Element Presence Checking

Both parameters are arrays:

const arr = [1, 2, 3, 4, 5];
assertArrayIncludes(arr, [2, 4]);     // Passes - both present
assertArrayIncludes(arr, [4, 2]);     // Passes - order doesn't matter
assertArrayIncludes(arr, [6]);        // Fails - 6 not in array
Enter fullscreen mode Exit fullscreen mode

Single element checking: Wrap in array:

assertArrayIncludes(arr, [3]);  // Check if 3 is present
Enter fullscreen mode Exit fullscreen mode

5.6.1.2 Deep Equality for Objects

Objects compared by value:

const users = [{ id: 1 }, { id: 2 }];
assertArrayIncludes(users, [{ id: 1 }]);  // Passes
Enter fullscreen mode Exit fullscreen mode

Typed arrays supported:

assertArrayIncludes(Uint8Array.from([1, 2, 3, 4]), Uint8Array.from([1, 2]));
Enter fullscreen mode Exit fullscreen mode

5.6.2 assertObjectMatch() Function

5.6.2.1 Deep Object Comparison

Partial matching: First object must contain second object's structure:

const response = { id: 1, name: "Test", timestamp: Date.now() };
assertObjectMatch(response, { id: 1, name: "Test" });  // Passes
// Extra 'timestamp' property is allowed
Enter fullscreen mode Exit fullscreen mode

5.6.2.2 Partial Matching Behavior

Extra properties allowed:

assertObjectMatch({ a: 1, b: 2, c: 3 }, { a: 1 });  // Passes
Enter fullscreen mode Exit fullscreen mode

Missing properties fail:

assertObjectMatch({ a: 1 }, { a: 1, b: 2 });  // Fails - 'b' missing
Enter fullscreen mode Exit fullscreen mode

5.7 Error Assertions

5.7.1 assertThrows() Function

5.7.1.1 Sync Function Testing

Wrapper function syntax:

assertThrows(
  () => getLongUuid(0),    // Function that should throw
  Deno.errors.InvalidData  // Expected error type
);
Enter fullscreen mode Exit fullscreen mode

Pass on exception: Throwing the expected error type causes pass.

Fail on no exception: Normal return causes AssertionError.

5.7.1.2 Error Class Matching

Specify error class:

assertThrows(() => parseConfig(null), Deno.errors.InvalidData);
assertThrows(() => divide(1, 0), RangeError);
assertThrows(() => JSON.parse('invalid'), SyntaxError);
Enter fullscreen mode Exit fullscreen mode

5.7.1.3 Error Message Validation

Use msgIncludes parameter:

assertThrows(
  () => validateAge(-1),
  RangeError,
  "must be positive",  // Error message must include this
  "Age validation should reject negative values"  // Test failure message
);
Enter fullscreen mode Exit fullscreen mode

5.7.2 assertThrowsAsync() Function

5.7.2.1 Async Function Testing

Async wrapper:

await assertThrowsAsync(
  async () => await fetchData("invalid-url"),
  Error,
  "network error"
);
Enter fullscreen mode Exit fullscreen mode

Must await the assertion.

5.7.2.2 Promise Rejection Handling

Rejections treated as thrown errors:

await assertThrowsAsync(
  async () => await Promise.reject(new Error("Failed")),
  Error,
  "Failed"
);
Enter fullscreen mode Exit fullscreen mode

6. Advanced Testing Features

6.1 Parallel Execution

6.1.1 The --jobs Parameter

6.1.1.1 Worker-Based Parallelism

Configure workers:

deno test --jobs 4   # Run up to 4 test files simultaneously
Enter fullscreen mode Exit fullscreen mode

Workers run in isolated execution contexts. Output from different files interleaves.

6.1.1.2 Suite-Level (Not Test-Level) Parallelism

Important clarification:

"It's important to note that the test runner doesn't go inside a file and run the tests cases in parallel. Instead, it runs the suites in parallel."

Design for parallelism by splitting tests across multiple files to benefit from --jobs.

6.1.2 Performance Benefits

Quantified speedup from source documentation:

Sequential (2 files): ~19 seconds
Parallel --jobs 2:    ~9.7 seconds (49% reduction)
Enter fullscreen mode Exit fullscreen mode

"It takes almost half the time! This would significantly reduce the CI/CD time when there is a big test suite to execute."

6.1.3 Parallelism Considerations

Handle shared resources: Database tests may conflict if writing to same rows. Use namespace isolation (covered in Section 11).

Avoid port conflicts: Services binding to the same port will fail in parallel execution.


6.2 Conditional Test Execution

6.2.1 The ignore Option

6.2.1.1 Environment-Based Skipping

Deno.test({
  name: "slow integration test",
  ignore: Deno.env.get('QUICK_TEST') ? true : false,
  fn: async () => { /* test code */ }
});
Enter fullscreen mode Exit fullscreen mode

Skip integration tests by default:

Deno.test({
  name: "database integration",
  ignore: !Deno.env.get('TEST_TYPE_INTEG'),  // Skip unless integration mode
  fn: async () => { /* ... */ }
});
Enter fullscreen mode Exit fullscreen mode

6.2.1.2 Platform-Specific Tests

Deno.test({
  name: "Windows-specific test",
  ignore: Deno.build.os !== "windows",
  fn: () => { /* ... */ }
});
Enter fullscreen mode Exit fullscreen mode

6.2.2 The only Option

6.2.2.1 Focused Test Execution

Deno.test({
  name: "debug this test",
  only: true,  // Run only this test
  fn: () => { /* ... */ }
});
Enter fullscreen mode Exit fullscreen mode

Use during development to isolate failing tests.

6.2.2.2 Warning About only in CI

CI fails if only is present:

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 5 filtered out
FAILED because the "only" option was used
Enter fullscreen mode Exit fullscreen mode

This safety feature prevents accidentally deploying code that runs only one test.

6.2.3 The --filter Flag

6.2.3.1 Name-Based Filtering

Filter by substring:

deno test --filter "user"       # Run tests with "user" in name
deno test --filter "database"   # Run tests with "database" in name
Enter fullscreen mode Exit fullscreen mode

6.2.3.2 Running Test Subsets

From source documentation:

deno test --filter 100 a_test.ts
# Output:
# running 2 tests from file:///Users/.../a_test.ts
# test len is 100000 ... ok (400ms)
# test len is 1000000 ... ok (3666ms)
# test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out
Enter fullscreen mode Exit fullscreen mode

6.3 Fail-Fast Mode

6.3.1 The --fail-fast Flag

deno test --fail-fast
Enter fullscreen mode Exit fullscreen mode

Stops at first failure with immediate error details. Useful for rapid iteration during development.

6.3.2 Use Cases for Early Termination

  • Development: Quick identification of failures during TDD
  • Debugging: Focus on first failing test without noise
  • CI consideration: May want full reports instead—disable for complete results

6.4 Resource Sanitization

6.4.1 sanitizeOps Option

Deno tracks async operations. Leaked operations (unresolved promises) fail tests by default.

Deno.test({
  name: "my test",
  sanitizeOps: false,  // Disable operation leak checking
  fn: () => { /* ... */ }
});
Enter fullscreen mode Exit fullscreen mode

6.4.2 sanitizeResources Option

Tracks file handles, network connections, etc.

Deno.test({
  name: "my test",
  sanitizeResources: false,  // Disable resource leak checking
  fn: () => { /* ... */ }
});
Enter fullscreen mode Exit fullscreen mode

Warning: Disabling masks real issues. Fix leaks properly with try/finally.

6.4.3 sanitizeExit Option

Detects calls to Deno.exit():

Deno.test({
  name: "test exit behavior",
  sanitizeExit: false,  // Allow Deno.exit() in test
  fn: () => { /* ... */ }
});
Enter fullscreen mode Exit fullscreen mode

6.4.4 Debugging Resource Leaks

Identify leaked resources from sanitizer error messages:

AssertionError: Test case is leaking resources.
- file (rid 3)
Enter fullscreen mode Exit fullscreen mode

Fix leaks systematically:

  • Close file handles: file.close()
  • Clear timers: clearTimeout(timer)
  • Close connections: conn.close()

7. Testing Supabase Edge Functions

7.1 Understanding Edge Functions Architecture

7.1.1 Serverless Computing Model

Supabase Edge Functions run on Deno at the edge, close to users globally. From the documentation:

"Supabase Edge Functions are an integral part of the Supabase ecosystem, empowering developers to extend the functionality of their applications with serverless computing."

Benefits:

  • Automatic scaling based on demand
  • Reduced latency through edge deployment
  • No server infrastructure management

7.1.2 TypeScript Support

Native TypeScript without compilation:

"Edge Functions can be written in TypeScript, a statically-typed superset of JavaScript. TypeScript brings type safety to your code, enabling early detection of potential errors."

7.1.3 Supabase Client Integration

Seamless integration with Supabase client:

"Edge Functions seamlessly integrates with the Supabase client, enabling easy communication with your Supabase backend."


7.2 Local Development Setup

7.2.1 Starting the Supabase Server

supabase start
Enter fullscreen mode Exit fullscreen mode

Output includes:

  • API URL: http://localhost:54321
  • anon key: JWT for anonymous access
  • service_role key: JWT for admin access

7.2.2 Serving Edge Functions Locally

supabase functions serve
Enter fullscreen mode Exit fullscreen mode

This starts a local server with hot-reloading for Edge Functions.

7.2.3 Environment Configuration

7.2.3.1 SUPABASE_URL Configuration

# .env.local
SUPABASE_URL=http://localhost:54321
Enter fullscreen mode Exit fullscreen mode

7.2.3.2 SUPABASE_ANON_KEY Configuration

# .env.local (demo key for local development)
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
Enter fullscreen mode Exit fullscreen mode

Key roles:

  • anon: Public access, subject to RLS policies
  • service_role: Admin access, bypasses RLS

7.3 Writing Edge Function Tests

7.3.1 Supabase Client Setup

7.3.1.1 Client Configuration Options

import { createClient, SupabaseClient } from 'npm:@supabase/supabase-js@2';
import 'jsr:@std/dotenv/load';

const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? '';
const supabaseKey = Deno.env.get('SUPABASE_ANON_KEY') ?? '';

const options = {
  auth: {
    autoRefreshToken: false,
    persistSession: false,
    detectSessionInUrl: false,
  },
};

const client: SupabaseClient = createClient(supabaseUrl, supabaseKey, options);
Enter fullscreen mode Exit fullscreen mode

7.3.1.2 Authentication Options

  • autoRefreshToken: false — Prevent background token refresh for test stability
  • persistSession: false — Avoid localStorage usage for test isolation
  • detectSessionInUrl: false — Prevent URL parsing for clean test contexts

7.3.2 Testing Database Connectivity

const testClientCreation = async () => {
  if (!supabaseUrl) throw new Error('supabaseUrl is required.');
  if (!supabaseKey) throw new Error('supabaseKey is required.');

  const { data: table_data, error: table_error } = await client
    .from('my_table')
    .select('*')
    .limit(1);

  if (table_error) {
    throw new Error('Invalid Supabase client: ' + table_error.message);
  }
  assert(table_data, 'Data should be returned from the query.');
};

Deno.test('Client Creation Test', testClientCreation);
Enter fullscreen mode Exit fullscreen mode

7.3.3 Testing Function Invocation

7.3.3.1 Using functions.invoke()

const { data, error } = await client.functions.invoke('hello-world', {
  body: { name: 'TestUser' }
});
Enter fullscreen mode Exit fullscreen mode

7.3.3.2 Request Body Construction

Pass JSON-serializable data in the body property. Include all required fields per your function's schema.

7.3.3.3 Response Validation

const testHelloWorld = async () => {
  const { data: func_data, error: func_error } = await client.functions.invoke('hello-world', {
    body: { name: 'bar' }
  });

  if (func_error) {
    throw new Error('Invalid response: ' + func_error.message);
  }

  console.log(JSON.stringify(func_data, null, 2));  // Debug output
  assertEquals(func_data.message, 'Hello bar!');
};

Deno.test('Hello-world Function Test', testHelloWorld);
Enter fullscreen mode Exit fullscreen mode

7.4 CORS Handling in Tests

7.4.1 Understanding CORS Headers

Required headers for Edge Functions:

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
Enter fullscreen mode Exit fullscreen mode

7.4.2 OPTIONS Request Handling

Preflight request handler:

if (req.method === 'OPTIONS') {
  return new Response('ok', { headers: corsHeaders });
}
Enter fullscreen mode Exit fullscreen mode

7.4.3 Testing CORS Compliance

Deno.test('OPTIONS returns CORS headers', async () => {
  const res = await fetch('http://localhost:54321/functions/v1/hello-world', {
    method: 'OPTIONS'
  });

  assertEquals(res.status, 200);
  assertExists(res.headers.get('Access-Control-Allow-Origin'));
});
Enter fullscreen mode Exit fullscreen mode

8. Integration Testing

8.1 Integration vs Unit Testing

8.1.1 Defining Integration Tests

Integration tests verify interactions between components—ensuring modules work correctly together. Unlike unit tests that isolate individual functions, integration tests exercise the system as a whole.

From the documentation:

"There are three entities in integration testing: Tester (test runner), SUT (system under test running as a service), and Other services (dependencies)."

8.1.2 When to Use Integration Tests

  • Test API endpoints: Verify HTTP request/response cycles
  • Test database interactions: Verify queries, constraints, triggers
  • Test service communication: Verify external API integrations

8.1.3 Test Pyramid Considerations

  • More unit tests, fewer integration tests (pyramid shape)
  • Integration tests are slower—budget time accordingly
  • Focus on critical paths and high-risk integrations

8.2 Integration Test Architecture

8.2.1 Tester-SUT-Services Model

┌──────────┐     HTTP     ┌──────────┐     depends     ┌──────────────┐
│  Tester  │ ──────────── │   SUT    │ ────────────── │   Services   │
│          │  requests    │          │                 │ (real/mock)  │
└──────────┘              └──────────┘                 └──────────────┘
Enter fullscreen mode Exit fullscreen mode

8.2.2 Local vs Remote SUT

  • Local: Use supabase start for local testing
  • Staging: Configure environment variables to point to staging URLs

8.2.3 Mock Services Configuration

Decide whether to use mocks or real services for dependencies based on test requirements (covered in Section 12).


8.3 Marking Tests by Type

8.3.1 Environment Variable Based Marking

// Unit test - runs by default
Deno.test({
  name: "unit test",
  ignore: Deno.env.get('TEST_TYPE_INTEG') ? true : false,
  fn: () => { /* unit test code */ }
});

// Integration test - requires explicit opt-in
Deno.test({
  name: "integration test",
  ignore: Deno.env.get('TEST_TYPE_INTEG') ? false : true,
  fn: async () => { /* integration test code */ }
});
Enter fullscreen mode Exit fullscreen mode

8.3.2 Separating Unit and Integration Suites

Alternative: Use separate directories:

tests/
├── unit/
│   ├── user_test.ts
│   └── auth_test.ts
└── integration/
    ├── api_test.ts
    └── database_test.ts
Enter fullscreen mode Exit fullscreen mode

Run selectively:

deno test tests/unit/           # Unit tests only
deno test tests/integration/    # Integration tests only
Enter fullscreen mode Exit fullscreen mode

8.3.3 Conditional Execution Patterns

Default to unit tests (no special configuration needed):

deno test --allow-env          # Runs unit tests, skips integration
Enter fullscreen mode Exit fullscreen mode

Opt-in integration tests:

export TEST_TYPE_INTEG=1
deno test --allow-env --allow-net  # Runs integration tests, skips unit
Enter fullscreen mode Exit fullscreen mode

8.4 Writing Integration Test Cases

8.4.1 HTTP-Based Test Structure

8.4.1.1 Fetch Request Construction

const res = await fetch('http://localhost:5000/data', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ key: 'value' })
});
Enter fullscreen mode Exit fullscreen mode

8.4.1.2 Response Awaiting

const res = await fetch('http://localhost:5000/data');
const data = await res.json();  // or res.text() for non-JSON
Enter fullscreen mode Exit fullscreen mode

8.4.1.3 Multi-Assert Validation

assert(res.ok === true);
assert(res.status === 200);
assert(res.redirected === false);
assertExists(res.headers.get('content-type'));
assertExists(data);
Enter fullscreen mode Exit fullscreen mode

8.4.2 Testing Different HTTP Methods

8.4.2.1 GET Request Testing

Deno.test("GET /data returns JSON", async () => {
  const res = await fetch('http://localhost:5000/data');
  const data = await res.json();

  assert(res.ok);
  assertEquals(res.status, 200);
  assertExists(data);
  assertStringIncludes(data.a.c.d, "deno");
});
Enter fullscreen mode Exit fullscreen mode

8.4.2.2 POST Request Testing

Deno.test("POST /data creates record", async () => {
  const res = await fetch('http://localhost:5000/data', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name: 'test' })
  });

  assertEquals(res.status, 201);
});
Enter fullscreen mode Exit fullscreen mode

8.4.2.3 Error Status Code Validation

Deno.test("GET / returns 404", async () => {
  const res = await fetch('http://localhost:5000');
  assert(res.ok === false);
  assert(res.status === 404);
});

Deno.test("POST / returns 405 Method Not Allowed", async () => {
  const res = await fetch('http://localhost:5000', { method: 'POST' });
  assert(res.status === 405);
});
Enter fullscreen mode Exit fullscreen mode

8.5 Running Integration Tests

8.5.1 Starting the Service Under Test

# Terminal 1: Start the server in background
deno run --allow-net --unstable myServer.ts &
Enter fullscreen mode Exit fullscreen mode

8.5.2 Setting Environment Variables

# Terminal 2: Enable integration tests
export TEST_TYPE_INTEG=1
Enter fullscreen mode Exit fullscreen mode

8.5.3 Executing the Test Suite

deno test --allow-env --allow-net myServer_test.ts
Enter fullscreen mode Exit fullscreen mode

Expected output:

running 6 tests from file:///Users/.../myServer_test.ts
test unit test - no numRids ... ignored (0ms)
test unit test - numRids=10 ... ignored (0ms)
test Integration test: GET / ... ok (31ms)
test Integration test: POST / ... ok (4ms)
test Integration test: GET /data ... ok (7ms)
test Integration test: GET /data numRids=100 ... ok (6ms)

test result: ok. 4 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out (75ms)
Enter fullscreen mode Exit fullscreen mode

9. CI/CD Integration

9.1 Processing Test Results

9.1.1 Limitations of Console Output

From the documentation:

"Deno's test runner comes with a number of useful features. However, it has some limitations as well. The biggest one is that the results are always produced in textual format and goes only to the console."

This creates challenges for CI/CD pipelines that need structured data.

9.1.2 Programmatic Result Processing

Workaround: Capture output via child processes and parse with regex.


9.2 Building a Test Executor

9.2.1 Child Process Execution

9.2.1.1 Using Deno.run()

const p = Deno.run({
  cmd: ["deno", "test", "--allow-all"],
  stdout: 'piped',
  stderr: 'piped',
  stdin: 'null'
});

const status = await p.status();
const output = await p.output();
Enter fullscreen mode Exit fullscreen mode

9.2.1.2 Piping stdout and stderr

Configure stdout: 'piped' to capture output. Use TextDecoder to convert bytes to string:

const rawOutput = await p.output();
const textOutput = new TextDecoder().decode(rawOutput);
Enter fullscreen mode Exit fullscreen mode

9.2.2 Basic Output Processing

9.2.2.1 Status Object (success, code)

const status = await p.status();
// status.success: boolean - true if all tests passed
// status.code: number - exit code (0 = success)
Enter fullscreen mode Exit fullscreen mode

9.2.2.2 Raw Output Capture

import { stripColor } from "jsr:@std/fmt/colors";

const out: any = {};
out.status = status.success;
if (!status.success) {
  out.log = stripColor(new TextDecoder().decode(await p.output()));
}
console.log(out);
// { status: true } or { status: false, log: "..." }
Enter fullscreen mode Exit fullscreen mode

9.2.3 JSON Output Generation

9.2.3.1 Basic JSON Structure

const out = { status: s.success };
if (!s.success) {
  out.log = stripColor(new TextDecoder().decode(await p.output()));
}
console.log(JSON.stringify(out));
Enter fullscreen mode Exit fullscreen mode

9.2.3.2 Summarized JSON Output

Parse the summary line with regex:

const tkns = l.matchAll(
  /test\sresult:\s(.*)(\d+)\spassed;\s(\d+)\sfailed;\s(\d+)\signored;\s(\d+)\smeasured;\s(\d+)\sfiltered\sout\s\((\d+)ms\)/gm
);
const tknsArr = Array.from(tkns)[0];
if (tknsArr) {
  out.passed = Number(tknsArr[2]);
  out.failed = Number(tknsArr[3]);
  out.ignored = Number(tknsArr[4]);
  out.measured = Number(tknsArr[5]);
  out.filteredOut = Number(tknsArr[6]);
  out.totalTimeTaken = Number(tknsArr[7]);
}
Enter fullscreen mode Exit fullscreen mode

9.2.3.3 Detailed JSON with Test Lists

{
  passedCases: ["test A", "test B"],
  failedCases: ["test C"],
  status: false,
  passed: 2,
  failed: 1,
  totalTimeTaken: 4561,
  errorLog: "..."
}
Enter fullscreen mode Exit fullscreen mode

9.3 Parsing Test Results

9.3.1 Regex-Based Parsing

Pattern for individual tests:

const tkns = l.matchAll(/test\s(.*)\s\.\.\.\s(.*)\s.*/gm);
const tknsArr = Array.from(tkns)[0];
if (tknsArr) {
  if (tknsArr[2] === "ok") {
    out.passedCases.push(tknsArr[1]);
  } else {
    out.failedCases.push(tknsArr[1]);
  }
}
Enter fullscreen mode Exit fullscreen mode

9.3.2 Extracting Pass/Fail Counts

Use Number() to convert matched strings to integers:

out.passed = Number(tknsArr[2]);
out.failed = Number(tknsArr[3]);
Enter fullscreen mode Exit fullscreen mode

9.3.3 Capturing Error Logs

Split on failures marker:

out.errorLog = decOutput.split("failures:")[1];
Enter fullscreen mode Exit fullscreen mode

9.4 GitHub Actions Configuration

9.4.1 Workflow File Structure

name: Deno Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: denoland/setup-deno@v1
        with:
          deno-version: v1.x

      - uses: supabase/setup-cli@v1
        with:
          version: latest
Enter fullscreen mode Exit fullscreen mode

9.4.2 Installing Dependencies

The setup-deno and setup-cli actions install Deno and Supabase CLI respectively.

9.4.3 Running Supabase Stack in CI

- name: Start Supabase (Lightweight)
  run: supabase start -x realtime,storage-api,imgproxy,studio
Enter fullscreen mode Exit fullscreen mode

Exclude unnecessary services to reduce boot time.

9.4.4 Executing Tests with Proper Permissions

- name: Run Tests
  run: deno test --allow-all supabase/functions/tests/
  env:
    SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

10. Mocking and Dependency Injection

10.1 Architectural Patterns for Testability

10.1.1 The Humble Handler Pattern

10.1.1.1 Thin Entry Points

Anti-pattern (coupled code):

// index.ts - Hard to test
Deno.serve(async (req) => {
  const supabase = createClient(Deno.env.get('SUPABASE_URL')!, ...);
  const { data } = await supabase.from('users').select('*');
  const response = await fetch('https://api.stripe.com/...');
  return new Response("Done");
});
Enter fullscreen mode Exit fullscreen mode

Problem: Cannot inject mock clients for testing.

10.1.1.2 Extracted Business Logic

Recommended pattern:

// core.ts - Testable business logic
export interface Dependencies {
  supabase: SupabaseClient;
  httpClient: (url: string, init?: RequestInit) => Promise<Response>;
  config: { stripeKey: string };
}

export async function processRequest(
  req: Request,
  deps: Dependencies
): Promise<Response> {
  const { data } = await deps.supabase.from('users').select('*');
  const response = await deps.httpClient('https://api.stripe.com/...', {
    headers: { Authorization: `Bearer ${deps.config.stripeKey}` }
  });
  return new Response("Done");
}

// index.ts - Thin handler
Deno.serve(async (req) => {
  const deps = {
    supabase: createClient(...),
    httpClient: globalThis.fetch.bind(globalThis),
    config: { stripeKey: Deno.env.get('STRIPE_KEY')! }
  };
  return processRequest(req, deps);
});
Enter fullscreen mode Exit fullscreen mode

10.1.2 Functional Core, Imperative Shell

10.1.2.1 Pure Functions for Logic

Write functions with no side effects:

function calculateDiscount(price: number, tier: string): number {
  if (tier === 'premium') return price * 0.2;
  if (tier === 'basic') return price * 0.1;
  return 0;
}
Enter fullscreen mode Exit fullscreen mode

Test with simple assertions—no mocking needed.

10.1.2.2 Side Effects at the Boundary

Push I/O to handler level:

// Handler does I/O
const userData = await db.getUser(userId);
const discount = calculateDiscount(userData.price, userData.tier);  // Pure
await db.applyDiscount(userId, discount);  // I/O
Enter fullscreen mode Exit fullscreen mode

10.1.3 Service Layer Extraction

10.1.3.1 Hexagonal Architecture Concepts

Define interfaces as ports:

interface PaymentService {
  createCharge(amount: number, customerId: string): Promise<ChargeResult>;
}
Enter fullscreen mode Exit fullscreen mode

Implement adapters for real and mock services:

class StripePaymentService implements PaymentService {
  async createCharge(amount, customerId) { /* real Stripe call */ }
}

class MockPaymentService implements PaymentService {
  async createCharge(amount, customerId) { return { id: 'mock_charge' }; }
}
Enter fullscreen mode Exit fullscreen mode

10.1.3.2 Semantic Mocking Benefits

Mock at service level rather than HTTP level:

// Semantic: Test business logic
mockPaymentService.createCharge(1000, 'cust_123');

// vs Low-level: Test HTTP implementation details
fetch('https://api.stripe.com/v1/charges', { ... });
Enter fullscreen mode Exit fullscreen mode

10.2 Dependency Injection Techniques

10.2.1 Constructor Injection

class OrderService {
  constructor(
    private db: DatabaseService,
    private payment: PaymentService
  ) {}

  async createOrder(data: OrderData) {
    await this.db.save(data);
    await this.payment.createCharge(data.amount, data.customerId);
  }
}
Enter fullscreen mode Exit fullscreen mode

10.2.2 Interface-Based Dependencies

export interface Dependencies {
  supabase: SupabaseClient;
  httpClient: typeof fetch;
  config: { stripeKey: string };
}

export async function processRequest(req: Request, deps: Dependencies) {
  // Use deps.supabase, deps.httpClient, etc.
}
Enter fullscreen mode Exit fullscreen mode

10.2.3 Configuration Objects

const config = {
  stripeKey: Deno.env.get('STRIPE_KEY')!,
  timeout: Number(Deno.env.get('TIMEOUT') ?? '5000'),
  debug: Deno.env.get('DEBUG') === 'true'
};

// Override in tests
const testConfig = { ...config, stripeKey: 'test_key', debug: true };
Enter fullscreen mode Exit fullscreen mode

10.3 Mocking the Fetch API

10.3.1 Using stub() from @std/testing/mock

10.3.1.1 Stubbing globalThis.fetch

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

const fetchStub = stub(globalThis, "fetch", (url) => {
  return Promise.resolve(new Response(JSON.stringify({ success: true })));
});

// Run test code that uses fetch()

fetchStub.restore();  // Always restore!
Enter fullscreen mode Exit fullscreen mode

10.3.1.2 Restoring Original Behavior

Use try/finally for guaranteed restoration:

const fetchStub = stub(globalThis, "fetch", ...);
try {
  await runTestCode();
} finally {
  fetchStub.restore();
}
Enter fullscreen mode Exit fullscreen mode

10.3.2 Pattern-Based Mocking

10.3.2.1 URL Pattern Matching

const fetchStub = stub(globalThis, "fetch", (input) => {
  const url = input.toString();
  if (url.includes("api.stripe.com")) {
    return Promise.resolve(new Response(JSON.stringify({ id: "ch_123" })));
  }
  if (url.includes("api.sendgrid.com")) {
    return Promise.resolve(new Response(JSON.stringify({ sent: true })));
  }
  return Promise.reject(new Error("Unknown URL: " + url));
});
Enter fullscreen mode Exit fullscreen mode

10.3.2.2 Returning Controlled Responses

new Response(
  JSON.stringify({ data: "mock" }),
  { 
    status: 200,
    headers: { "Content-Type": "application/json" }
  }
);
Enter fullscreen mode Exit fullscreen mode

10.4 Spying on Function Calls

10.4.1 Creating Spy Functions

import { spy } from "jsr:@std/testing/mock";

const mockStripe = {
  createCheckoutSession: spy(async (priceId, customerId) => {
    return { id: "cs_123", url: "https://checkout.stripe.com/..." };
  })
};
Enter fullscreen mode Exit fullscreen mode

10.4.2 Verifying Call Count (assertSpyCalls)

import { assertSpyCalls } from "jsr:@std/testing/mock";

// After running code that should call the function
assertSpyCalls(mockStripe.createCheckoutSession, 1);  // Called exactly once
Enter fullscreen mode Exit fullscreen mode

10.4.3 Inspecting Call Arguments

// Check what arguments were passed
assertEquals(mockStripe.createCheckoutSession.calls[0].args[0], "price_premium");
assertEquals(mockStripe.createCheckoutSession.calls[0].args[1], "cust_123");
Enter fullscreen mode Exit fullscreen mode

10.5 Mocking the Supabase Client

10.5.1 When to Mock vs Use Real Client

  • Mock for unit tests: Fast, isolated, no database needed
  • Real for integration tests: Verify actual SQL, constraints, RLS

10.5.2 Creating Mock Client Objects

const mockSupabase = {
  from: (table: string) => ({
    select: () => ({
      eq: () => ({
        single: () => Promise.resolve({
          data: { id: 1, name: 'Test User' },
          error: null
        })
      })
    }),
    insert: (data: any) => Promise.resolve({ data, error: null })
  })
};
Enter fullscreen mode Exit fullscreen mode

10.5.3 Simulating Database Responses

Match the { data, error } response pattern:

// Successful response
{ data: [{ id: 1 }, { id: 2 }], error: null }

// Error response
{ data: null, error: { message: 'Row not found' } }
Enter fullscreen mode Exit fullscreen mode

11. Database Testing and Isolation

11.1 Database Isolation Strategies

11.1.1 Transaction Rollback Method

11.1.1.1 BEGIN...ROLLBACK Wrapping

BEGIN;
-- Test operations here
INSERT INTO users (name) VALUES ('Test');
SELECT * FROM users WHERE name = 'Test';
ROLLBACK;  -- Undoes all changes
Enter fullscreen mode Exit fullscreen mode

Perfect isolation: Database returns to original state.

11.1.1.2 Limitations with HTTP Clients

The Supabase JavaScript client uses HTTP requests through PostgREST, which are stateless. You cannot maintain a transaction across multiple HTTP requests.

Recommendation: Use namespace isolation for Supabase JS tests.

11.1.2 Namespace Isolation Method

11.1.2.1 Unique ID Generation

const userId = crypto.randomUUID();
const email = `test-${userId}@example.com`;
Enter fullscreen mode Exit fullscreen mode

Every test generates unique identifiers.

11.1.2.2 Scoped Data Creation

All test data links to the unique user:

await supabase.from('orders').insert({
  user_id: userId,  // Scoped to this test
  amount: 100
});
Enter fullscreen mode Exit fullscreen mode

11.1.2.3 Cleanup with service_role

Use the admin client to delete test users:

// service_role bypasses RLS
await adminClient.auth.admin.deleteUser(userId);
Enter fullscreen mode Exit fullscreen mode

With ON DELETE CASCADE foreign keys, related data is automatically cleaned.

11.1.3 Truncation Method

11.1.3.1 DELETE FROM Approach

await supabase.from('orders').delete().neq('id', 0);  // Delete all
await supabase.from('users').delete().neq('id', 0);
Enter fullscreen mode Exit fullscreen mode

Problems:

  • Slow for large tables
  • Cannot run tests in parallel
  • Risk of running against production

11.1.3.2 Risks and Limitations

Prefer namespace isolation for most scenarios.

11.1.4 Docker Per Test Method

Ultimate isolation but impractical:

  • Docker startup takes seconds per test
  • High resource usage
  • Not suitable for regular development

11.2 Implementing Namespace Isolation

11.2.1 Unique User Creation

const userId = crypto.randomUUID();
const email = `test-${userId}@example.com`;

await adminClient.auth.admin.createUser({
  id: userId,
  email,
  email_confirm: true
});
Enter fullscreen mode Exit fullscreen mode

11.2.2 Cascading Deletes with Foreign Keys

Configure in database schema:

CREATE TABLE orders (
  id UUID PRIMARY KEY,
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE
);
Enter fullscreen mode Exit fullscreen mode

Deleting the user automatically deletes their orders.

11.2.3 Teardown Patterns

Deno.test("User operations", async (t) => {
  const userId = crypto.randomUUID();
  // ... setup and test steps ...

  await t.step("Teardown", async () => {
    const { error } = await adminClient.auth.admin.deleteUser(userId);
    if (error) console.error("Cleanup failed:", error);
  });
});
Enter fullscreen mode Exit fullscreen mode

11.3 Database Seeding with Factories

11.3.1 The Factory Pattern

const Factory = {
  createUser: async (opts: { plan?: string } = {}) => {
    const id = crypto.randomUUID();
    await supabase.from('users').insert({
      id,
      plan: opts.plan ?? 'free'
    });
    return { id };
  },

  createOrder: async (opts: { userId: string; amount: number }) => {
    const id = crypto.randomUUID();
    await supabase.from('orders').insert({
      id,
      user_id: opts.userId,
      amount: opts.amount
    });
    return { id };
  }
};
Enter fullscreen mode Exit fullscreen mode

11.3.2 Creating User Factories

const user = await Factory.createUser({ plan: 'premium' });
Enter fullscreen mode Exit fullscreen mode

11.3.3 Creating Related Data Factories

// Complex scenario: User with pending invoices
const user = await Factory.createUser({ plan: 'basic' });
await Factory.createOrder({ userId: user.id, amount: 100 });
await Factory.createInvoice({ userId: user.id, status: 'pending' });
Enter fullscreen mode Exit fullscreen mode

11.4 Testing Row Level Security (RLS)

11.4.1 Creating Admin and User Clients

// Admin client (bypasses RLS)
const adminClient = createClient(url, serviceRoleKey);

// User client (subject to RLS)
const userClient = createClient(url, anonKey, {
  global: {
    headers: { Authorization: `Bearer ${userJwt}` }
  }
});
Enter fullscreen mode Exit fullscreen mode

11.4.2 JWT Generation for Testing

import { create } from "https://deno.land/x/djwt@v2.8/mod.ts";

async function mintToken(userId: string, secret: string) {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    true,
    ["sign", "verify"]
  );

  return create(
    { alg: "HS256", typ: "JWT" },
    { sub: userId, role: "authenticated", exp: Date.now() / 1000 + 3600 },
    key
  );
}
Enter fullscreen mode Exit fullscreen mode

11.4.3 Verifying Access Restrictions

Deno.test("User cannot access others' data", async () => {
  // User 1 creates data
  const { data: created } = await user1Client.from('todos').insert({ task: 'Private' });

  // User 2 tries to read it
  const { data: read } = await user2Client.from('todos').select().eq('id', created.id);

  // Should be empty due to RLS
  assertEquals(read.length, 0);
});
Enter fullscreen mode Exit fullscreen mode

12. Third-Party Service Testing

12.1 Testing Strategy Tiers

12.1.1 Tier 1: Virtual Mocks (Spying)

Speed: <10ms
Reliability: High (no external dependencies)

const fetchStub = stub(globalThis, "fetch", () => {
  return Promise.resolve(new Response(JSON.stringify({ id: "evt_123" })));
});
Enter fullscreen mode Exit fullscreen mode

Best for: Testing internal logic paths ("If Stripe returns 402, downgrade the user")

12.1.1.1 Speed and Reliability Benefits

  • Instantaneous execution
  • Works offline
  • Never fails due to external service outages

12.1.1.2 Testing Internal Logic Paths

  • Test error handling for various status codes
  • Test retry logic and fallback behavior
  • Limitation: Doesn't verify request correctness

12.1.2 Tier 2: VCR Pattern (Record/Replay)

Speed: <20ms (after recording)
Reliability: High (frozen responses)

12.1.2.1 Recording Live Responses

First run makes real requests and saves responses to JSON files.

UPDATE_SNAPSHOTS=true deno test
Enter fullscreen mode Exit fullscreen mode

12.1.2.2 Replaying from Cassettes

Subsequent runs read from saved files instead of network.

12.1.2.3 Snapshot Update Mechanism

Re-record when APIs change:

UPDATE_SNAPSHOTS=true deno test
git diff cassettes/  # Review changes
Enter fullscreen mode Exit fullscreen mode

12.1.3 Tier 3: Live Sandbox Execution

Speed: 500ms-5s
Reliability: Low (external dependencies)

12.1.3.1 When to Use Live Tests

  • Nightly or pre-release runs
  • Verifying API contracts haven't changed
  • Testing authentication credentials

12.1.3.2 Dedicated Sandbox Credentials

  • Never use production keys
  • Use Stripe Test Mode, Mailgun Sandbox, etc.
  • Isolate from production traffic

12.1.3.3 Cleanup Requirements

  • Delete created customers, subscriptions
  • Cancel test charges
  • Avoid "zombie data" in sandbox dashboards

12.2 Comparing Testing Strategies

Feature Tier 1: Mock Tier 2: VCR Tier 3: Live
Speed <10ms <20ms 500ms-5s
Reliability High High Low
Fidelity Low Medium High
Cost Free Free API usage
Offline Yes Yes No

12.3 Implementing Service Mocks

12.3.1 Stripe Service Mocking

const mockStripe = {
  createCheckoutSession: spy(async (priceId) => ({
    id: "cs_test_123",
    url: "https://checkout.stripe.com/pay/cs_test_123"
  })),

  constructWebhookEvent: spy((body, sig) => ({
    type: "checkout.session.completed",
    data: { object: { id: "cs_test_123" } }
  }))
};
Enter fullscreen mode Exit fullscreen mode

12.3.2 Email Service Mocking

const mockEmail = {
  send: spy(async (opts) => {
    // Verify email content in test assertions
    return { messageId: "mock_msg_123" };
  })
};
Enter fullscreen mode Exit fullscreen mode

12.3.3 OpenAI/LLM Service Mocking

const mockOpenAI = {
  chat: {
    completions: {
      create: spy(async (opts) => ({
        choices: [{ message: { content: "Mock AI response" } }]
      }))
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

12.4 Webhook Testing

12.4.1 Signature Verification Testing

Do not disable verification in tests. Instead, generate valid signatures.

12.4.2 Generating Valid Test Signatures

async function generateStripeSignature(payload: string, secret: string) {
  const timestamp = Math.floor(Date.now() / 1000);
  const data = `${timestamp}.${payload}`;

  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"]
  );

  const signature = await crypto.subtle.sign(
    "HMAC",
    key,
    new TextEncoder().encode(data)
  );

  const sigHex = Array.from(new Uint8Array(signature))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');

  return `t=${timestamp},v1=${sigHex}`;
}
Enter fullscreen mode Exit fullscreen mode

12.4.3 Testing Webhook Handlers

Deno.test("Webhook processes payment_intent.succeeded", async () => {
  const payload = JSON.stringify({ type: "payment_intent.succeeded", ... });
  const signature = await generateStripeSignature(payload, webhookSecret);

  const res = await fetch('http://localhost:54321/functions/v1/stripe-webhook', {
    method: 'POST',
    headers: { 'stripe-signature': signature },
    body: payload
  });

  assertEquals(res.status, 200);

  // Verify side effects
  const { data } = await supabase.from('payments').select().eq('stripe_id', '...');
  assertEquals(data[0].status, 'completed');
});
Enter fullscreen mode Exit fullscreen mode

13. Best Practices and Anti-Patterns

13.1 Test Organization Best Practices

13.1.1 One Assertion Purpose Per Test

Focus tests narrowly:

// Good: Single focus
Deno.test("returns error when input is empty", ...);
Deno.test("returns user object when found", ...);

// Bad: Multiple behaviors in one test
Deno.test("handles all edge cases", ...);
Enter fullscreen mode Exit fullscreen mode

13.1.2 Descriptive Test Names

// Good: Describes scenario and outcome
Deno.test("getLongUuid: throws InvalidData when length exceeds maximum", ...);

// Bad: Generic
Deno.test("test 1", ...);
Deno.test("works", ...);
Enter fullscreen mode Exit fullscreen mode

13.1.3 Avoiding Test Interdependencies

Each test stands alone:

// Bad: Tests depend on each other
let userId: string;
Deno.test("create user", async () => { userId = await createUser(); });
Deno.test("get user", async () => { await getUser(userId); });  // Fails if first test skipped!

// Good: Each test creates its own state
Deno.test("get user returns data", async () => {
  const userId = await createUser();  // Self-contained
  const user = await getUser(userId);
  assertEquals(user.id, userId);
});
Enter fullscreen mode Exit fullscreen mode

13.2 Common Anti-Patterns

13.2.1 Coupled Handler Code

Problem: Business logic mixed with infrastructure makes testing impossible without mocking everything.

Solution: Extract logic to testable functions (Humble Handler pattern).

13.2.2 Global State Pollution

Problem: Shared mutable state causes tests to affect each other.

Solution: Create fresh state per test; clean up after.

13.2.3 Unreliable Async Handling

Problem: Missing await causes false positives.

// Bad: Missing await
Deno.test("async test", () => {
  assertThrowsAsync(async () => await fetch(...));  // Returns immediately!
});

// Good: Await the assertion
Deno.test("async test", async () => {
  await assertThrowsAsync(async () => await fetch(...));
});
Enter fullscreen mode Exit fullscreen mode

13.2.4 Forgetting to Restore Stubs

Problem: Leaked stubs affect subsequent tests.

// Always use try/finally
const stub = stub(globalThis, "fetch", ...);
try {
  // test code
} finally {
  stub.restore();
}
Enter fullscreen mode Exit fullscreen mode

13.3 Performance Optimization

13.3.1 Minimizing Cold Starts

  • Keep functions small
  • Limit dependencies
  • Use lazy loading for heavy imports

13.3.2 Using Fat Functions Strategy

Group related logic in single functions to keep runtime warm:

// Instead of many tiny functions
/functions/create-user/
/functions/update-user/
/functions/delete-user/

// Consider one function with routing
/functions/user-api/  // Handles all user operations
Enter fullscreen mode Exit fullscreen mode

13.3.3 Excluding Unnecessary Services in CI

supabase start -x realtime,storage-api,imgproxy,studio
Enter fullscreen mode Exit fullscreen mode

Significant time savings by starting only required services.


13.4 Security Testing

13.4.1 Testing with Different User Personas

// Admin client
const adminClient = createClient(url, serviceRoleKey);

// Regular user client
const userClient = createClient(url, anonKey);

// Anonymous client (no auth)
const anonClient = createClient(url, anonKey);
Enter fullscreen mode Exit fullscreen mode

13.4.2 Verifying RLS Policy Enforcement

Test that users:

  • Can read their own data
  • Cannot read others' data
  • Cannot modify data they don't own

13.4.3 Protecting Test Credentials

  • Never log secrets in assertions
  • Use GitHub Secrets, not code
  • Rotate immediately if exposed

14. Complete Examples and Recipes

14.1 Unit Test Suite Example

import { assertEquals, assertThrows, assertExists } from "jsr:@std/assert@1";

// Function under test
function getRandomString(len: number): string {
  if (len === 0) throw new Deno.errors.InvalidData("len cannot be 0");
  if (len % 11 !== 0) throw new Deno.errors.InvalidData("len must be a multiple of 11");

  const rand = () => Math.random().toString(36).slice(2);
  let output = "";
  for (let i = 0; i < len; i += 11) output += rand();
  return output;
}

// Unit Tests
Deno.test("getRandomString: throws on zero length", () => {
  assertThrows(
    () => getRandomString(0),
    Deno.errors.InvalidData,
    "cannot be 0"
  );
});

Deno.test("getRandomString: throws on non-multiple of 11", () => {
  assertThrows(
    () => getRandomString(5),
    Deno.errors.InvalidData,
    "must be a multiple"
  );
});

Deno.test("getRandomString: returns correct length for 11", () => {
  const result = getRandomString(11);
  assertExists(result);
  assertEquals(typeof result, 'string');
  assertEquals(result.length, 11);
});

Deno.test("getRandomString: returns correct length for 55", () => {
  const result = getRandomString(55);
  assertExists(result);
  assertEquals(result.length, 55);
});
Enter fullscreen mode Exit fullscreen mode

14.2 Integration Test Suite Example

import { assert, assertEquals, assertExists, assertStringIncludes } from "jsr:@std/assert@1";

// Unit tests (run by default)
Deno.test({
  name: "unit test - data processing",
  ignore: Deno.env.get('TEST_TYPE_INTEG') ? true : false,
  fn: () => {
    const data = processData({ input: "test" });
    assertExists(data);
    assertEquals(data.status, "processed");
  }
});

// Integration tests (require TEST_TYPE_INTEG=1)
Deno.test({
  name: "Integration: GET / returns 404",
  ignore: !Deno.env.get('TEST_TYPE_INTEG'),
  fn: async () => {
    const res = await fetch('http://localhost:5000');
    assert(res.ok === false);
    assertEquals(res.status, 404);
  }
});

Deno.test({
  name: "Integration: GET /data returns JSON",
  ignore: !Deno.env.get('TEST_TYPE_INTEG'),
  fn: async () => {
    const res = await fetch('http://localhost:5000/data');
    const data = await res.json();

    assert(res.ok);
    assertEquals(res.status, 200);
    assertExists(data);
    assertStringIncludes(data.message, "Welcome");
  }
});

Deno.test({
  name: "Integration: POST / returns 405",
  ignore: !Deno.env.get('TEST_TYPE_INTEG'),
  fn: async () => {
    const res = await fetch('http://localhost:5000', { method: 'POST' });
    assertEquals(res.status, 405);
  }
});
Enter fullscreen mode Exit fullscreen mode

14.3 Edge Function Test Example

import { assert, assertEquals, assertExists } from 'jsr:@std/assert@1';
import { createClient, SupabaseClient } from 'npm:@supabase/supabase-js@2';
import 'jsr:@std/dotenv/load';

// Configuration
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? '';
const supabaseKey = Deno.env.get('SUPABASE_ANON_KEY') ?? '';

const options = {
  auth: {
    autoRefreshToken: false,
    persistSession: false,
    detectSessionInUrl: false,
  },
};

// Test: Client Creation
const testClientCreation = async () => {
  const client: SupabaseClient = createClient(supabaseUrl, supabaseKey, options);

  if (!supabaseUrl) throw new Error('supabaseUrl is required.');
  if (!supabaseKey) throw new Error('supabaseKey is required.');

  const { data, error } = await client
    .from('test_table')
    .select('*')
    .limit(1);

  if (error) {
    throw new Error('Invalid Supabase client: ' + error.message);
  }
  assert(data, 'Data should be returned from the query.');
};

// Test: Edge Function Invocation
const testHelloWorld = async () => {
  const client: SupabaseClient = createClient(supabaseUrl, supabaseKey, options);

  const { data, error } = await client.functions.invoke('hello-world', {
    body: { name: 'Deno' },
  });

  if (error) {
    throw new Error('Invalid response: ' + error.message);
  }

  console.log(JSON.stringify(data, null, 2));
  assertEquals(data.message, 'Hello Deno!');
};

// Register Tests
Deno.test('Client Creation Test', testClientCreation);
Deno.test('Hello-world Function Test', testHelloWorld);
Enter fullscreen mode Exit fullscreen mode

14.4 Full CI/CD Pipeline Example

name: Deno Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Deno
        uses: denoland/setup-deno@v1
        with:
          deno-version: v1.x

      - name: Setup Supabase CLI
        uses: supabase/setup-cli@v1
        with:
          version: latest

      - name: Start Supabase (Lightweight)
        run: supabase start -x realtime,storage-api,imgproxy,studio

      - name: Run Database Migrations
        run: supabase db push

      - name: Run Unit Tests
        run: deno test --allow-all supabase/functions/tests/

      - name: Run Integration Tests
        run: deno test --allow-all supabase/functions/tests/
        env:
          TEST_TYPE_INTEG: "1"
          SUPABASE_URL: "http://localhost:54321"
          SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
          SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}

      - name: Stop Supabase
        if: always()
        run: supabase stop
Enter fullscreen mode Exit fullscreen mode

Appendix

A. Quick Reference: Common Assertions

Assertion Purpose Example
assert(expr) Verify truthy assert(count > 0)
assertEquals(a, b) Deep equality assertEquals(result, expected)
assertNotEquals(a, b) Deep inequality assertNotEquals(a, b)
assertStrictEquals(a, b) Reference equality assertStrictEquals(obj1, obj1)
assertExists(val) Not null/undefined assertExists(user)
assertThrows(fn) Expect sync error assertThrows(() => fn())
assertThrowsAsync(fn) Expect async error await assertThrowsAsync(async () => ...)
assertMatch(str, re) Regex match assertMatch(id, /^[a-z]+$/)
assertStringIncludes(a, b) Substring check assertStringIncludes("hello", "ell")
assertArrayIncludes(a, b) Array contains assertArrayIncludes([1,2,3], [2])
assertObjectMatch(a, b) Partial object match assertObjectMatch(obj, {id: 1})

B. Quick Reference: CLI Commands

Command Purpose
deno test Run all tests in current directory
deno test path/ Run tests in specific directory
deno test file.ts Run specific test file
deno test --jobs 4 Run tests in parallel (4 workers)
deno test --filter "name" Run tests matching name
deno test --fail-fast Stop on first failure
deno test --allow-all Grant all permissions
deno test --allow-net --allow-env Grant specific permissions
supabase start Start local Supabase stack
supabase functions serve Serve Edge Functions locally
supabase start -x studio,realtime Start with excluded services

This guide was compiled from official Supabase documentation, Deno documentation, and community best practices. For the latest updates, consult the official documentation at supabase.com/docs and deno.land/manual.

Top comments (0)