Paths / Building Workflows / Testing Workflows Locally
Lesson 6 of 6
25 min

Testing Workflows Locally

Use wrangler dev and mocking for local development.

Learning Objectives

By the end of this lesson, you will be able to:

  • Start the local development server with wrangler dev and understand Cloudflare Workers runtime
  • Test workflows using curl with custom JSON payloads
  • Create mock integrations using Vitest patterns from the codebase
  • Write unit tests with proper mocking of storage, integrations, and AI
  • Debug workflows using wrangler tail and structured logging

Test before deploy. Find bugs locally, not in production. Wrangler brings the Cloudflare Workers environment to your machine.

Step-by-Step: Test Your Workflow Locally

Step 1: Start the Development Server

WORKWAY workflows run on Cloudflare Workers. Use wrangler dev to start locally:

cd packages/workers/my-worker
wrangler dev

You'll see:

⎔ Starting local server...
[wrangler] Ready on http://localhost:8787

Step 2: Configure wrangler.toml

Every worker needs a wrangler.toml configuration:

name = "my-workflow"
main = "src/index.ts"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]

# Environment variables (non-secret)
[vars]
WORKER_URL = "https://my-workflow.workway.co"

# Durable Objects for stateful workflows
[[durable_objects.bindings]]
name = "SESSIONS"
class_name = "UserSession"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["UserSession"]

# Cron triggers
[triggers]
crons = ["0 7 * * *"]  # Daily at 7 AM UTC

Step 3: Set Up Environment Variables

Create .dev.vars for local secrets (gitignored):

# OAuth credentials for development
ZOOM_CLIENT_ID=your_dev_client_id
ZOOM_CLIENT_SECRET=your_dev_client_secret

# API secrets
API_SECRET=your_dev_secret
UPLOAD_SECRET=your_dev_upload_secret

# Optional: Use real APIs instead of mocks
USE_REAL_APIS=false

Step 4: Trigger a Test Execution

In a new terminal:

curl http://localhost:8787/execute

Check the dev server terminal for execution logs.

Step 5: Test with Custom Payload

Simulate a webhook event:

curl http://localhost:8787/execute \
  -H "Content-Type: application/json" \
  -d '{
    "object": {
      "id": "test-meeting-123",
      "topic": "Test Meeting",
      "duration": 45
    }
  }'

Step 6: View Live Logs

Monitor real-time logs from your deployed worker:

# Stream all logs
wrangler tail

# Filter by status
wrangler tail --status error

# JSON format for parsing
wrangler tail --format json

Step 7: Hot Reload Development

Edit your workflow code. Wrangler automatically reloads:

[wrangler] Detected changes, restarting...
[wrangler] Ready on http://localhost:8787

Mocking Patterns for Testing

WORKWAY uses Vitest for testing with mock patterns from the codebase.

Creating Mock Storage

From packages/workflows/src/notion-two-way-sync/index.test.ts:

import { describe, it, expect, beforeEach, vi } from 'vitest';

function createMockStorage() {
  const store = new Map<string, unknown>();

  return {
    get: vi.fn(async <T>(key: string): Promise<T | undefined> => {
      return store.get(key) as T | undefined;
    }),
    set: vi.fn(async (key: string, value: unknown): Promise<void> => {
      store.set(key, value);
    }),
    delete: vi.fn(async (key: string): Promise<void> => {
      store.delete(key);
    }),
    // Test helpers
    _store: store,
    _clear: () => store.clear(),
  };
}

Creating Mock Integration Clients

function createMockNotionClient() {
  return {
    getPage: vi.fn().mockResolvedValue({
      success: true,
      data: {
        id: 'page-123',
        properties: {
          Name: { title: [{ text: { content: 'Test Item' } }] },
          Status: { status: { name: 'New' } },
        },
      },
    }),
    createPage: vi.fn().mockResolvedValue({
      success: true,
      data: { id: 'new-page-456' },
    }),
    updatePage: vi.fn().mockResolvedValue({
      success: true,
      data: { id: 'page-123' },
    }),
  };
}

function createMockAI() {
  return {
    generateText: vi.fn().mockResolvedValue({
      success: true,
      data: { response: 'AI-generated summary of the content.' },
    }),
  };
}

Complete Test Setup

describe('My Workflow', () => {
  let storage: ReturnType<typeof createMockStorage>;
  let notion: ReturnType<typeof createMockNotionClient>;
  let ai: ReturnType<typeof createMockAI>;

  beforeEach(() => {
    storage = createMockStorage();
    notion = createMockNotionClient();
    ai = createMockAI();
  });

  it('should create page from webhook event', async () => {
    const trigger = {
      data: {
        type: 'meeting.ended',
        object: { id: 'meeting-123', topic: 'Team Standup' },
      },
    };

    const result = await executeWorkflow({
      trigger,
      storage,
      integrations: { notion, ai },
    });

    expect(result.success).toBe(true);
    expect(notion.createPage).toHaveBeenCalled();
  });
});

Testing Idempotency

Prevent duplicate processing:

it('should skip duplicate events within window', async () => {
  const timestamp = '2024-01-15T10:30:00.000Z';

  // Simulate recent processing
  storage._store.set(`sync:page-123:${timestamp}`, {
    syncedAt: Date.now(),
  });

  const trigger = {
    data: {
      page_id: 'page-123',
      timestamp,
    },
  };

  const result = await executeWorkflow({ trigger, storage, integrations });

  expect(result.success).toBe(true);
  expect(result.skipped).toBe(true);
  expect(result.reason).toContain('loop prevention');
});

Testing Error Scenarios

it('should handle missing mapping gracefully', async () => {
  // No mapping exists for this page
  const trigger = {
    data: { page_id: 'orphan-page', type: 'page.updated' },
  };

  const result = await executeWorkflow({ trigger, storage, integrations });

  expect(result.success).toBe(false);
  expect(result.error).toContain('No mapping found');
  expect(result.hint).toBeDefined();
});

it('should handle API failures', async () => {
  notion.createPage.mockResolvedValue({
    success: false,
    error: 'Database not found',
  });

  const result = await executeWorkflow({ trigger, storage, integrations });

  expect(result.success).toBe(false);
  expect(result.error).toContain('Failed to create');
});

Development Server Commands

Start Local Development

wrangler dev

This starts:

  • Local HTTP server on localhost:8787
  • Hot reload on file changes
  • Simulated Cloudflare Workers runtime
  • Access to Durable Objects (local mode)
  • Request/response logging

Common Wrangler Commands

# Start dev server
wrangler dev

# Start on specific port
wrangler dev --port 3000

# Deploy to production
wrangler deploy

# View live logs
wrangler tail

# Create D1 database
wrangler d1 create my-database

# Create KV namespace
wrangler kv:namespace create MY_KV

# Set secrets (production)
wrangler secret put API_SECRET

Trigger Test Execution

# Basic trigger
curl http://localhost:8787/execute

# With payload
curl http://localhost:8787/execute \
  -H "Content-Type: application/json" \
  -d '{"meetingId": "123"}'

# With trigger type
curl http://localhost:8787/execute?trigger=webhook \
  -H "Content-Type: application/json" \
  -d '{"event": "meeting.ended", "payload": {"object": {"id": "123"}}}'

Environment Variables

.dev.vars (Local Development)

Create .dev.vars in your worker directory:

# OAuth credentials
GOOGLE_CLIENT_ID=your_client_id
GOOGLE_CLIENT_SECRET=your_client_secret
NOTION_CLIENT_ID=your_client_id
NOTION_CLIENT_SECRET=your_client_secret

# API secrets
JWT_SECRET=your_dev_jwt_secret
API_SECRET=your_dev_api_secret

# Feature flags
USE_REAL_APIS=true
DEBUG=true

Production Secrets

Set production secrets via Wrangler:

wrangler secret put JWT_SECRET
# Enter your secret when prompted

wrangler secret put GOOGLE_CLIENT_SECRET
wrangler secret put API_SECRET

Accessing Environment Variables

In your workflow code:

async execute({ env }) {
  // Access vars from wrangler.toml [vars]
  const workerUrl = env.WORKER_URL;

  // Access secrets from .dev.vars or wrangler secret
  const apiSecret = env.API_SECRET;

  // Feature flag check
  const useRealApis = env.USE_REAL_APIS === 'true';

  if (useRealApis) {
    console.log('Using real API calls');
  }
}

Testing Strategies

Unit Testing with Vitest

Test individual helper functions:

// src/utils.ts
export function formatMeetingTitle(topic: string, date: Date): string {
  return `${topic} - ${date.toLocaleDateString()}`;
}

// src/utils.test.ts
import { describe, it, expect } from 'vitest';
import { formatMeetingTitle } from './utils';

describe('formatMeetingTitle', () => {
  it('includes topic and date', () => {
    const result = formatMeetingTitle('Standup', new Date('2024-01-15'));
    expect(result).toBe('Standup - 1/15/2024');
  });
});

Run tests:

pnpm test

Integration Testing with Mocks

Test complete workflow execution using the mock patterns shown above:

// src/index.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';

describe('Meeting to Notion Workflow', () => {
  let storage: ReturnType<typeof createMockStorage>;
  let notion: ReturnType<typeof createMockNotionClient>;
  let zoom: ReturnType<typeof createMockZoomClient>;
  let ai: ReturnType<typeof createMockAI>;

  beforeEach(() => {
    storage = createMockStorage();
    notion = createMockNotionClient();
    zoom = createMockZoomClient();
    ai = createMockAI();
  });

  it('creates Notion page from meeting webhook', async () => {
    const trigger = {
      data: {
        event: 'meeting.ended',
        object: { id: 'meeting-123', topic: 'Team Standup' },
      },
    };

    const result = await executeWorkflow({
      trigger,
      storage,
      integrations: { notion, zoom, ai },
      inputs: { notionDatabaseId: 'db-123' },
    });

    expect(result.success).toBe(true);
    expect(notion.createPage).toHaveBeenCalledWith(
      expect.objectContaining({
        parentDatabaseId: 'db-123',
      })
    );
  });
});

Testing with Assertions

Verify specific API call patterns:

it('passes meeting data to Notion correctly', async () => {
  zoom.getMeeting.mockResolvedValue({
    success: true,
    data: {
      id: '123',
      topic: 'Planning Session',
      duration: 45,
      start_time: '2024-01-15T10:00:00Z',
    },
  });

  await executeWorkflow({ trigger, storage, integrations });

  expect(notion.createPage).toHaveBeenCalledWith(
    expect.objectContaining({
      properties: expect.objectContaining({
        Name: { title: [{ text: { content: 'Planning Session' } }] },
      }),
    })
  );
});

Debugging

Structured Console Logging

Use console methods that show in wrangler tail:

async execute({ trigger, env }) {
  const triggerId = crypto.randomUUID().slice(0, 8);

  console.log('[START]', { triggerId, event: trigger.type });

  try {
    const meeting = await fetchMeeting(trigger.data.meetingId);
    console.log('[MEETING]', { triggerId, meetingId: meeting.id });

    const page = await createNotionPage(meeting);
    console.log('[SUCCESS]', { triggerId, pageId: page.id });

    return { success: true, pageId: page.id };
  } catch (error) {
    console.error('[ERROR]', { triggerId, error: error.message });
    throw error;
  }
}

Using wrangler tail

Monitor deployed worker logs in real-time:

# Stream all logs
wrangler tail

# Filter errors only
wrangler tail --status error

# JSON format for piping to jq
wrangler tail --format json | jq '.logs[]'

# Sample 10% of requests (for high-traffic workers)
wrangler tail --sampling-rate 0.1

Local Debugging with VS Code

Create .vscode/launch.json:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug Worker",
      "type": "node",
      "request": "launch",
      "runtimeExecutable": "npx",
      "runtimeArgs": ["wrangler", "dev", "--local"],
      "skipFiles": ["<node_internals>/**"],
      "cwd": "${workspaceFolder}/packages/workers/my-worker"
    }
  ]
}

Debug Logging in wrangler.toml

Enable verbose logging:

[dev]
log_level = "debug"

Inspecting Request/Response

Log integration calls for debugging:

async function debugFetch(url: string, options: RequestInit) {
  console.log('[FETCH]', { url, method: options.method });

  const response = await fetch(url, options);
  const data = await response.json();

  console.log('[RESPONSE]', {
    url,
    status: response.status,
    dataPreview: JSON.stringify(data).slice(0, 200),
  });

  return data;
}

Common Issues

"Integration not mocked"

Your mock doesn't cover the method being called:

// If you call zoom.getMeetingTranscript() but only mock getMeeting()
// Error: Integration method not mocked: zoom.getMeetingTranscript

// Fix: Add missing mock method
function createMockZoomClient() {
  return {
    getMeeting: vi.fn().mockResolvedValue({ success: true, data: {} }),
    getMeetingTranscript: vi.fn().mockResolvedValue({
      success: true,
      data: { text: 'Mock transcript' },
    }), // Add missing method
  };
}

"Cannot connect to localhost:8787"

Development server not running:

# Terminal 1: Start wrangler
cd packages/workers/my-worker
wrangler dev

# Terminal 2: Test commands
curl localhost:8787/execute

"Durable Object not found"

Missing migration in wrangler.toml:

# Add migration for new Durable Object classes
[[migrations]]
tag = "v1"
new_sqlite_classes = ["UserSession"]

"Secret not found in .dev.vars"

Environment variable missing:

# Check .dev.vars exists and has the variable
cat .dev.vars

# Should contain:
# API_SECRET=your_secret

"TypeScript errors on mock"

Mock doesn't match interface:

// Zoom.getMeeting returns Meeting type
// Your mock is missing required fields

// Fix: Match the full interface
zoom.getMeeting.mockResolvedValue({
  success: true,
  data: {
    id: '123',
    topic: 'Test',
    start_time: new Date().toISOString(), // Required field
    duration: 30,
    participants: [], // Required field
  },
});

Test Fixtures

Shared Test Data

// tests/fixtures/meetings.ts
export const mockMeeting = {
  id: '123',
  topic: 'Weekly Standup',
  start_time: '2024-01-15T10:00:00Z',
  duration: 30,
  participants: [
    { name: 'Alice', email: '[email protected]' },
    { name: 'Bob', email: '[email protected]' },
  ],
};

export const mockTranscript = {
  text: 'Alice: Hello. Bob: Hi.',
  speaker_segments: [
    { speaker: 'Alice', text: 'Hello.' },
    { speaker: 'Bob', text: 'Hi.' },
  ],
};

Factory Functions

// tests/fixtures/factories.ts
export function createMeeting(overrides = {}) {
  return {
    id: crypto.randomUUID(),
    topic: 'Test Meeting',
    start_time: new Date().toISOString(),
    duration: 30,
    participants: [],
    ...overrides,
  };
}

// Usage
const shortMeeting = createMeeting({ duration: 10 });
const longMeeting = createMeeting({ duration: 120, topic: 'Planning' });

Event Factory Functions

// tests/fixtures/events.ts
export function createPageCreatedEvent(
  pageId: string,
  databaseId: string,
  timestamp?: string
) {
  return {
    type: 'page.created',
    page_id: pageId,
    id: pageId,
    parent: { database_id: databaseId },
    timestamp: timestamp || new Date().toISOString(),
  };
}

export function createWebhookEvent(type: string, data: object) {
  return {
    type,
    timestamp: new Date().toISOString(),
    ...data,
  };
}

Pre-Deploy Checklist

Before wrangler deploy:

  • All tests pass (pnpm test)
  • No TypeScript errors (pnpm tsc --noEmit)
  • Tested with realistic mock data
  • Error cases handled
  • Structured logging added for debugging
  • Secrets configured (wrangler secret put)
  • wrangler.toml configured correctly
# Full check
pnpm test && pnpm tsc --noEmit && wrangler deploy

Praxis

Set up a complete local testing environment:

Praxis: Create a test file with mocked storage and integrations for your workflow

Create a test suite using Vitest and the mock patterns:

// src/index.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';

// Mock factories (copy from above)
function createMockStorage() { /* ... */ }
function createMockNotionClient() { /* ... */ }
function createMockAI() { /* ... */ }

describe('My Workflow', () => {
  let storage: ReturnType<typeof createMockStorage>;
  let notion: ReturnType<typeof createMockNotionClient>;
  let ai: ReturnType<typeof createMockAI>;

  beforeEach(() => {
    storage = createMockStorage();
    notion = createMockNotionClient();
    ai = createMockAI();
  });

  it('creates page from webhook event', async () => {
    const trigger = {
      data: { type: 'meeting.ended', object: { id: '123' } },
    };

    const result = await executeWorkflow({
      trigger,
      storage,
      integrations: { notion, ai },
    });

    expect(result.success).toBe(true);
    expect(notion.createPage).toHaveBeenCalled();
  });

  it('handles API failures gracefully', async () => {
    notion.createPage.mockResolvedValue({
      success: false,
      error: 'Database not found',
    });

    const result = await executeWorkflow({
      trigger,
      storage,
      integrations: { notion, ai },
    });

    expect(result.success).toBe(false);
    expect(result.error).toBeDefined();
  });
});

Run the development flow:

# Terminal 1: Start wrangler dev
cd packages/workers/my-worker
wrangler dev

# Terminal 2: Run tests
pnpm test

# Terminal 3: Manual testing
curl localhost:8787/execute -d '{"test": true}'

# Monitor deployed logs
wrangler tail

Reflection

  • How does local testing with wrangler change your development confidence?
  • What edge cases should your workflow handle?
  • When should you use real APIs vs. mocks in development?
  • How do Durable Objects affect your testing strategy?

Praxis — Hands-on Exercise

Set up local testing with mocks. Create a test suite and run workway dev, workway test, and curl commands.