ElevenLabs CI Integration
Overview
Set up CI/CD pipelines that test ElevenLabs integrations without burning character quota on every PR. Uses a two-tier strategy: mocked unit tests on every push, gated integration tests on demand.
Prerequisites
- GitHub repository with Actions enabled
- ElevenLabs API key for integration tests
- npm/pnpm project with vitest configured
Instructions
Step 1: GitHub Actions Workflow
# .github/workflows/elevenlabs-tests.yml
name: ElevenLabs Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
# Tier 1: Always runs — no API key needed, no quota cost
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm test -- --coverage
env:
# Mock mode — no real API calls
ELEVENLABS_API_KEY: "sk_test_mock_key_for_ci"
# Tier 2: Only on main or manual trigger — uses real API
integration-tests:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
# Check quota before running integration tests
- name: Check ElevenLabs quota
env:
ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }}
run: |
REMAINING=$(curl -s https://api.elevenlabs.io/v1/user \
-H "xi-api-key: ${ELEVENLABS_API_KEY}" | \
jq '.subscription | (.character_limit - .character_count)')
echo "Characters remaining: $REMAINING"
if [ "$REMAINING" -lt 5000 ]; then
echo "::warning::Low ElevenLabs quota ($REMAINING chars). Skipping integration tests."
echo "SKIP_INTEGRATION=true" >> $GITHUB_ENV
fi
- name: Run integration tests
if: env.SKIP_INTEGRATION != 'true'
env:
ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }}
ELEVENLABS_INTEGRATION: "1"
run: npm run test:integration
Step 2: Configure Repository Secrets
# Store API key as GitHub secret (use a test/dev key, NOT production)
gh secret set ELEVENLABS_API_KEY --body "sk_your_test_key_here"
# Optional: webhook secret for webhook tests
gh secret set ELEVENLABS_WEBHOOK_SECRET --body "whsec_your_secret_here"
Step 3: Unit Test with SDK Mock
// tests/unit/tts-service.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the entire SDK — no API calls, no quota usage
vi.mock("@elevenlabs/elevenlabs-js", () => ({
ElevenLabsClient: vi.fn().mockImplementation(() => ({
textToSpeech: {
convert: vi.fn().mockResolvedValue(
new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([0xFF, 0xFB, 0x90, 0x00])); // MP3 header
controller.close();
},
})
),
stream: vi.fn().mockImplementation(async function* () {
yield new Uint8Array([0xFF, 0xFB, 0x90, 0x00]);
}),
},
voices: {
getAll: vi.fn().mockResolvedValue({
voices: [
{ voice_id: "21m00Tcm4TlvDq8ikWAM", name: "Rachel", category: "premade" },
],
}),
},
user: {
get: vi.fn().mockResolvedValue({
subscription: { tier: "pro", character_count: 1000, character_limit: 500000 },
}),
},
})),
}));
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
describe("TTS Service", () => {
let client: InstanceType<typeof ElevenLabsClient>;
beforeEach(() => {
client = new ElevenLabsClient();
});
it("should call TTS with correct parameters", async () => {
await client.textToSpeech.convert("21m00Tcm4TlvDq8ikWAM", {
text: "Test speech",
model_id: "eleven_multilingual_v2",
voice_settings: { stability: 0.5, similarity_boost: 0.75 },
});
expect(client.textToSpeech.convert).toHaveBeenCalledWith(
"21m00Tcm4TlvDq8ikWAM",
expect.objectContaining({
text: "Test speech",
model_id: "eleven_multilingual_v2",
})
);
});
it("should handle voice listing", async () => {
const result = await client.voices.getAll();
expect(result.voices).toHaveLength(1);
expect(result.voices[0].name).toBe("Rachel");
});
});
Step 4: Integration Test (Gated)
// tests/integration/tts-smoke.test.ts
import { describe, it, expect } from "vitest";
const SKIP = !process.env.ELEVENLABS_INTEGRATION;
describe.skipIf(SKIP)("ElevenLabs Integration", () => {
it("should generate audio from text", async () => {
const { ElevenLabsClient } = await import("@elevenlabs/elevenlabs-js");
const client = new ElevenLabsClient();
// Use Flash model + short text to minimize quota usage
const audio = await client.textToSpeech.convert("21m00Tcm4TlvDq8ikWAM", {
text: "CI test.", // 8 characters = 4 credits (Flash)
model_id: "eleven_flash_v2_5",
output_format: "mp3_22050_32",
});
expect(audio).toBeDefined();
}, 30_000);
it("should list voices", async () => {
const { ElevenLabsClient } = await import("@elevenlabs/elevenlabs-js");
const client = new ElevenLabsClient();
const { voices } = await client.voices.getAll();
expect(voices.length).toBeGreaterThan(0);
});
});
Step 5: Package Scripts
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest --watch",
"test:integration": "ELEVENLABS_INTEGRATION=1 vitest run tests/integration/",
"test:ci": "vitest run --coverage --reporter=junit --outputFile=test-results.xml"
}
}
CI Strategy Summary
| Tier | When | API Key | Quota Cost | Coverage | |------|------|---------|------------|----------| | Unit tests | Every push/PR | Mock key | 0 characters | SDK integration patterns | | Integration | Main + manual | Real test key | ~50 chars | End-to-end TTS verification | | Quota check | Before integration | Real test key | 0 (GET only) | Prevents surprise billing |
Error Handling
| Issue | Cause | Solution |
|-------|-------|----------|
| Secret not found in CI | Missing repository secret | gh secret set ELEVENLABS_API_KEY |
| Integration tests timeout | Slow TTS generation | Increase test timeout to 30s; use Flash model |
| Quota depleted in CI | Too many integration runs | Use quota guard; limit to main branch only |
| Mock drift | SDK API changed | Update mocks when upgrading SDK |
Resources
Next Steps
For deployment patterns, see elevenlabs-deploy-integration.