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-nodeinstalled- Separate Customer.io workspace for development (recommended — free workspaces available)
dotenvor similar for environment variable loading
Instructions
Step 1: Environment Configuration
# .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
// 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<string, any>) {
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<string, any>) {
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
// __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
// 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:
# Load dev env and run integration tests
npx dotenv -e .env.development -- npx vitest run tests/customerio.integration.test.ts
Step 5: Dev Scripts
// 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
Next Steps
After setting up local dev, proceed to customerio-sdk-patterns for production-ready patterns.