--- name: customerio-local-dev-loop description: 'Configure Customer.io local development workflow. Use when setting up local testing, dev/staging isolation, or mocking Customer.io for unit tests. Trigger: "customer.io local dev", "test customer.io locally", "customer.io dev environment", "customer.io sandbox", "mock customer.io". ' allowed-tools: Read, Write, Edit, Bash(npm:*), Bash(npx:*), Glob, Grep version: 1.0.0 license: MIT author: Jeremy Longshore tags: - saas - customer-io - testing - development - workflow compatibility: Designed for Claude Code, also compatible with Codex and OpenClaw --- # Customer.io Local Dev Loop ## Overview Set up an efficient local development workflow for Customer.io: environment isolation via separate workspaces, a dry-run client for safe development, test mocks for unit tests, and prefixed events that never pollute production data. ## Prerequisites - `customerio-node` installed - Separate Customer.io workspace for development (recommended — free workspaces available) - `dotenv` or similar for environment variable loading ## Instructions ### Step 1: Environment Configuration ```bash # .env.development CUSTOMERIO_SITE_ID=dev-site-id CUSTOMERIO_TRACK_API_KEY=dev-track-key CUSTOMERIO_APP_API_KEY=dev-app-key CUSTOMERIO_REGION=us CUSTOMERIO_DRY_RUN=false CUSTOMERIO_EVENT_PREFIX=dev_ # .env.test CUSTOMERIO_SITE_ID=not-needed CUSTOMERIO_TRACK_API_KEY=not-needed CUSTOMERIO_APP_API_KEY=not-needed CUSTOMERIO_DRY_RUN=true CUSTOMERIO_EVENT_PREFIX=test_ ``` ### Step 2: Environment-Aware Client ```typescript // lib/customerio-dev.ts import { TrackClient, APIClient, RegionUS, RegionEU } from "customerio-node"; interface CioConfig { siteId: string; trackApiKey: string; appApiKey: string; region: typeof RegionUS | typeof RegionEU; dryRun: boolean; eventPrefix: string; } function loadConfig(): CioConfig { return { siteId: process.env.CUSTOMERIO_SITE_ID ?? "", trackApiKey: process.env.CUSTOMERIO_TRACK_API_KEY ?? "", appApiKey: process.env.CUSTOMERIO_APP_API_KEY ?? "", region: process.env.CUSTOMERIO_REGION === "eu" ? RegionEU : RegionUS, dryRun: process.env.CUSTOMERIO_DRY_RUN === "true", eventPrefix: process.env.CUSTOMERIO_EVENT_PREFIX ?? "", }; } export class DevTrackClient { private client: TrackClient | null = null; private config: CioConfig; private log: typeof console.log; constructor() { this.config = loadConfig(); this.log = console.log.bind(console); if (!this.config.dryRun) { this.client = new TrackClient( this.config.siteId, this.config.trackApiKey, { region: this.config.region } ); } } async identify(userId: string, attributes: Record) { const prefixedId = `${this.config.eventPrefix}${userId}`; if (this.config.dryRun) { this.log("[DRY RUN] identify:", prefixedId, attributes); return; } return this.client!.identify(prefixedId, attributes); } async track(userId: string, eventName: string, data?: Record) { const prefixedId = `${this.config.eventPrefix}${userId}`; const prefixedEvent = `${this.config.eventPrefix}${eventName}`; if (this.config.dryRun) { this.log("[DRY RUN] track:", prefixedId, prefixedEvent, data); return; } return this.client!.track(prefixedId, { name: prefixedEvent, data, }); } async suppress(userId: string) { const prefixedId = `${this.config.eventPrefix}${userId}`; if (this.config.dryRun) { this.log("[DRY RUN] suppress:", prefixedId); return; } return this.client!.suppress(prefixedId); } } ``` ### Step 3: Test Mocks for Unit Tests ```typescript // __mocks__/customerio-node.ts (for vitest/jest auto-mocking) import { vi } from "vitest"; export const TrackClient = vi.fn().mockImplementation(() => ({ identify: vi.fn().mockResolvedValue(undefined), track: vi.fn().mockResolvedValue(undefined), trackAnonymous: vi.fn().mockResolvedValue(undefined), suppress: vi.fn().mockResolvedValue(undefined), destroy: vi.fn().mockResolvedValue(undefined), mergeCustomers: vi.fn().mockResolvedValue(undefined), })); export const APIClient = vi.fn().mockImplementation(() => ({ sendEmail: vi.fn().mockResolvedValue({ delivery_id: "mock-delivery-123" }), sendPush: vi.fn().mockResolvedValue({ delivery_id: "mock-push-456" }), triggerBroadcast: vi.fn().mockResolvedValue(undefined), })); export const RegionUS = "us"; export const RegionEU = "eu"; export const SendEmailRequest = vi.fn().mockImplementation((data) => data); export const SendPushRequest = vi.fn().mockImplementation((data) => data); ``` ### Step 4: Integration Test with Real API ```typescript // tests/customerio.integration.test.ts import { describe, it, expect, afterAll } from "vitest"; import { TrackClient, RegionUS } from "customerio-node"; const TEST_PREFIX = `test_${Date.now()}_`; const testUserIds: string[] = []; const cio = new TrackClient( process.env.CUSTOMERIO_SITE_ID!, process.env.CUSTOMERIO_TRACK_API_KEY!, { region: RegionUS } ); function testUserId(label: string): string { const id = `${TEST_PREFIX}${label}`; testUserIds.push(id); return id; } describe("Customer.io Integration", () => { afterAll(async () => { // Clean up all test users for (const id of testUserIds) { await cio.suppress(id).catch(() => {}); await cio.destroy(id).catch(() => {}); } }); it("should identify a user", async () => { const id = testUserId("identify"); await expect( cio.identify(id, { email: `${id}@test.example.com` }) ).resolves.not.toThrow(); }); it("should track an event", async () => { const id = testUserId("track"); await cio.identify(id, { email: `${id}@test.example.com` }); await expect( cio.track(id, { name: "test_event", data: { step: 1 } }) ).resolves.not.toThrow(); }); it("should reject invalid credentials", async () => { const badClient = new TrackClient("bad-id", "bad-key", { region: RegionUS, }); await expect( badClient.identify("x", { email: "x@test.com" }) ).rejects.toThrow(); }); }); ``` Run integration tests only against your dev workspace: ```bash # Load dev env and run integration tests npx dotenv -e .env.development -- npx vitest run tests/customerio.integration.test.ts ``` ### Step 5: Dev Scripts ```json // package.json scripts { "scripts": { "cio:verify": "dotenv -e .env.development -- tsx scripts/verify-customerio.ts", "cio:test": "dotenv -e .env.development -- vitest run tests/customerio.integration.test.ts", "cio:test:dry": "CUSTOMERIO_DRY_RUN=true vitest run tests/customerio" } } ``` ## Workspace Isolation Strategy | Environment | Workspace Name | Event Prefix | Dry Run | |-------------|---------------|--------------|---------| | Unit tests | (mocked) | `test_` | true | | Integration tests | `myapp-dev` | `inttest_` | false | | Staging | `myapp-staging` | (none) | false | | Production | `myapp-prod` | (none) | false | ## Error Handling | Error | Cause | Solution | |-------|-------|----------| | Dev events in production | Wrong `.env` file loaded | Verify `NODE_ENV` and env file path | | Mock not intercepting | Import order issue | Mock `customerio-node` before importing your client module | | Test user pollution | No cleanup | Always suppress + destroy test users in `afterAll` | ## Resources - [Customer.io Workspaces](https://docs.customer.io/accounts-and-workspaces/managing-credentials/) - [customerio-node GitHub](https://github.com/customerio/customerio-node) ## Next Steps After setting up local dev, proceed to `customerio-sdk-patterns` for production-ready patterns.