You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Electric E2E Test Infrastructure Analysis - Complete Documentation
Summary
You now have comprehensive documentation on Electric's e2e test setup, derived from examining their TypeScript client test infrastructure. The documentation is ready to be used as a blueprint for implementing similar patterns in TanStack DB.
What You'll Find Here
Four complementary documents totaling over 2400 lines and 60KB:
ELECTRIC_E2E_PATTERNS.md - Deep dive into architecture, patterns, and design decisions
QUICK_REFERENCE.md - Quick lookup guide with copy-paste templates
ACTUAL_CODE_EXCERPTS.md - Real working code from Electric's test suite
ELECTRIC_E2E_INDEX.md - Navigation guide and quick reference index
Quick Start
If you want to understand the overall architecture:
Start with ELECTRIC_E2E_PATTERNS.md sections 1-3
If you want to copy code and get going:
Start with QUICK_REFERENCE.md and ACTUAL_CODE_EXCERPTS.md
If you want both understanding and practical code:
New to e2e testing? Start with ELECTRIC_E2E_PATTERNS.md Section 1-3
Want specific patterns? Use QUICK_REFERENCE.md or ELECTRIC_E2E_INDEX.md
Need working code? Go to ACTUAL_CODE_EXCERPTS.md
Lost? Check ELECTRIC_E2E_INDEX.md for topic mapping
Next Steps
Review ELECTRIC_E2E_PATTERNS.md to understand the approach
Copy relevant Docker configuration
Create global-setup.ts using templates
Implement test fixtures using the examples
Write your first parameterized test
Reference QUICK_REFERENCE.md for any questions
All code examples are actual implementations from Electric's test suite.
Generated from: ~/programs/electric/packages/typescript-client/test
Ready for TanStack DB implementation.
fileParallelism: false is essential for serial execution with shared database
Coverage reporters: istanbul with multiple output formats
junit output for CI/CD integration
TypeScript type checking enabled
2. Global Setup (test/support/global-setup.ts)
importtype{GlobalSetupContext}from'vitest/node'import{makePgClient}from'./test-helpers'consturl=process.env.ELECTRIC_URL??`http://localhost:3000`constproxyUrl=process.env.ELECTRIC_PROXY_CACHE_URL??`http://localhost:3002`// name of proxy cache container to execute commands against,// see docker-compose.yml that spins it up for detailsconstproxyCacheContainerName=`electric_dev-nginx-1`// path pattern for cache files inside proxy cache to clearconstproxyCachePath=`/var/cache/nginx/*`// eslint-disable-next-line quotes -- eslint is acting dumb with enforce backtick quotes mode, and is trying to use it here where it's not allowed.declare module 'vitest'{exportinterfaceProvidedContext{baseUrl: stringproxyCacheBaseUrl: stringtestPgSchema: stringproxyCacheContainerName: stringproxyCachePath: string}}functionwaitForElectric(url: string): Promise<void>{returnnewPromise<void>((resolve,reject)=>{consttimeout=setTimeout(()=>reject(`Timed out waiting for Electric to be active`),10000)consttryHealth=async()=>fetch(`${url}/v1/health`).then(async(res): Promise<void>=>{if(!res.ok)returntryHealth()const{ status }=(awaitres.json())as{status: string}if(status!==`active`)returntryHealth()clearTimeout(timeout)resolve()}).catch((err)=>{clearTimeout(timeout)reject(err)})returntryHealth()})}/** * Global setup for the test suite. Validates that our server is running, and creates and tears down a * special schema in Postgres to ensure clean slate between runs. */exportdefaultasyncfunction({ provide }: GlobalSetupContext){awaitwaitForElectric(url)constclient=makePgClient()awaitclient.connect()awaitclient.query(`CREATE SCHEMA IF NOT EXISTS electric_test`)provide(`baseUrl`,url)provide(`testPgSchema`,`electric_test`)provide(`proxyCacheBaseUrl`,proxyUrl)provide(`proxyCacheContainerName`,proxyCacheContainerName)provide(`proxyCachePath`,proxyCachePath)returnasync()=>{awaitclient.query(`DROP SCHEMA electric_test CASCADE`)awaitclient.end()}}
Key insights:
Health check recursively polls /v1/health endpoint
10-second timeout for server startup
Multiple environment variable overrides supported
Module augmentation for type-safe context injection
Cleanup function returns from main function
3. Test Context Fixtures (test/support/test-context.ts)
Base Database Client Fixture
exportconsttestWithDbClient=test.extend<{dbClient: Clientaborter: AbortControllerbaseUrl: stringpgSchema: stringclearShape: ClearShapeFn}>({dbClient: async({},use)=>{constsearchOption=`-csearch_path=${inject(`testPgSchema`)}`constclient=makePgClient({options: searchOption})awaitclient.connect()awaituse(client)awaitclient.end()},aborter: async({},use)=>{constcontroller=newAbortController()awaituse(controller)controller.abort(`Test complete`)},baseUrl: async({},use)=>use(inject(`baseUrl`)),pgSchema: async({},use)=>use(inject(`testPgSchema`)),clearShape: async({},use)=>{awaituse(async(table: string,options: {handle?: string}={})=>{constbaseUrl=inject(`baseUrl`)consturl=newURL(`${baseUrl}/v1/shape`)url.searchParams.set(`table`,table)if(options.handle){url.searchParams.set(SHAPE_HANDLE_QUERY_PARAM,options.handle)}constresp=awaitfetch(url.toString(),{method: `DELETE`})if(!resp.ok){// if we've been passed a shape handle then we should expect this delete call to succeed.if(resp.status===404){// the shape wasn't found, so maybe it wasn't created in the first place}else{console.error(awaitFetchError.fromResponse(resp,`DELETE ${url.toString()}`))thrownewError(`Could not delete shape ${table} with ID ${options.handle}`)}}})},})
Issues Table Fixture (Extends testWithDbClient)
exportconsttestWithIssuesTable=testWithDbClient.extend<{issuesTableSql: stringissuesTableUrl: stringissuesTableKey: stringupdateIssue: UpdateIssueFndeleteIssue: DeleteIssueFninsertIssues: InsertIssuesFnclearIssuesShape: ClearIssuesShapeFnwaitForIssues: WaitForIssuesFn}>({issuesTableSql: async({ dbClient, task },use)=>{consttableName=`"issues for ${task.id}_${Math.random().toString(16).replace(`.`,`_`)}"`awaitdbClient.query(` DROP TABLE IF EXISTS ${tableName}; CREATE TABLE ${tableName} ( id UUID PRIMARY KEY, title TEXT NOT NULL, priority INTEGER NOT NULL ); COMMENT ON TABLE ${tableName} IS 'Created for ${task.file?.name.replace(/'/g,`\``)??`unknown`} - ${task.name.replace(`'`,`\``)}'; `)awaituse(tableName)awaitdbClient.query(`DROP TABLE ${tableName}`)},issuesTableUrl: async({ issuesTableSql, pgSchema, clearShape },use)=>{consturlAppropriateTable=pgSchema+`.`+issuesTableSqlawaituse(urlAppropriateTable)try{awaitclearShape(urlAppropriateTable)}catch(_){// ignore - clearShape has its own logging// we don't want to interrupt cleanup}},issuesTableKey: ({ issuesTableSql, pgSchema },use)=>use(`"${pgSchema}".${issuesTableSql}`),updateIssue: ({ issuesTableSql, dbClient },use)=>use(({ id, title })=>dbClient.query(`UPDATE ${issuesTableSql} SET title = $2 WHERE id = $1`,[id,title,])),deleteIssue: ({ issuesTableSql, dbClient },use)=>use(({ id })=>dbClient.query(`DELETE FROM ${issuesTableSql} WHERE id = $1`,[id])),insertIssues: ({ issuesTableSql, dbClient },use)=>use(async(...rows)=>{constplaceholders=rows.map((_,i)=>`($${i*3+1}, $${i*3+2}, $${i*3+3})`)const{rows: rows_1}=awaitdbClient.query(`INSERT INTO ${issuesTableSql} (id, title, priority) VALUES ${placeholders} RETURNING id`,rows.flatMap((x)=>[x.id??uuidv4(),x.title,10]))returnrows_1.map((x)=>x.id)}),clearIssuesShape: async({ clearShape, issuesTableUrl },use)=>{use((handle?: string)=>clearShape(issuesTableUrl,{ handle }))},waitForIssues: ({ issuesTableUrl, baseUrl, aborter },use)=>use(({
numChangesExpected,
shapeStreamOptions,}: {numChangesExpected?: numbershapeStreamOptions?: Partial<ShapeStreamOptions>})=>waitForTransaction({
baseUrl,table: issuesTableUrl,
shapeStreamOptions,
numChangesExpected,
aborter,})),})
Key insights:
Tables named with task.id + random suffix for uniqueness
SQL comments include file name and test name for debugging
import{describe,expect,inject}from'vitest'import{testWithIssuesTableasit}from'./support/test-context'import{ShapeStream,Shape}from'../src'constBASE_URL=inject(`baseUrl`)constfetchAndSse=[{liveSse: false},{liveSse: true}]describe.for(fetchAndSse)(`Shape (liveSSE=$liveSse)`,({ liveSse })=>{it(`should sync an empty shape`,async({ issuesTableUrl, aborter })=>{conststart=Date.now()constshapeStream=newShapeStream({url: `${BASE_URL}/v1/shape`,params: {table: issuesTableUrl,},signal: aborter.signal,
liveSse,})constshape=newShape(shapeStream)expect(awaitshape.value).toEqual(newMap())expect(awaitshape.rows).toEqual([])expect(shape.lastSyncedAt()).toBeGreaterThanOrEqual(start)expect(shape.lastSyncedAt()).toBeLessThanOrEqual(Date.now())expect(shape.lastSynced()).toBeLessThanOrEqual(Date.now()-start)})it(`should throw on a reserved parameter`,async({ aborter })=>{expect(()=>{constshapeStream=newShapeStream({url: `${BASE_URL}/v1/shape`,params: {table: `foo`,// @ts-expect-error should not allow reserved parameterslive: `false`,},
liveSse,signal: aborter.signal,})newShape(shapeStream)}).toThrowErrorMatchingSnapshot()})it(`should notify with the initial value`,async({
issuesTableUrl,
insertIssues,
aborter,})=>{const[id]=awaitinsertIssues({title: `test title`})conststart=Date.now()constshapeStream=newShapeStream({url: `${BASE_URL}/v1/shape`,params: {table: issuesTableUrl,},signal: aborter.signal,
liveSse,})constshape=newShape(shapeStream)constrows=awaitnewPromise((resolve)=>{shape.subscribe(({ rows })=>resolve(rows))})expect(rows).toEqual([{id: id,title: `test title`,priority: 10}])expect(shape.lastSyncedAt()).toBeGreaterThanOrEqual(start)expect(shape.lastSyncedAt()).toBeLessThanOrEqual(Date.now())expect(shape.lastSynced()).toBeLessThanOrEqual(Date.now()-start)})})
7. Non-Database Tests with beforeEach/afterEach (test/expired-shapes-cache.test.ts)
import{beforeEach,afterEach,describe,expect,it,vi}from'vitest'import{ShapeStream}from'../src'import{ExpiredShapesCache,expiredShapesCache,}from'../src/expired-shapes-cache'import{EXPIRED_HANDLE_QUERY_PARAM}from'../src/constants'describe(`ExpiredShapesCache`,()=>{letcache: ExpiredShapesCacheconstshapeUrl=`https://example.com/v1/shape`letaborter: AbortControllerletfetchMock: ReturnType<typeofvi.fn>beforeEach(()=>{localStorage.clear()cache=newExpiredShapesCache()expiredShapesCache.clear()aborter=newAbortController()fetchMock=vi.fn()vi.clearAllMocks()})afterEach(()=>aborter.abort())it(`should mark shapes as expired and check expiration status`,()=>{constshapeUrl1=`https://example.com/v1/shape?table=test1`constshapeUrl2=`https://example.com/v1/shape?table=test2`consthandle1=`handle-123`// Initially, shape should not have expired handleexpect(cache.getExpiredHandle(shapeUrl1)).toBe(null)// Mark shape as expiredcache.markExpired(shapeUrl1,handle1)// Now shape should return expired handleexpect(cache.getExpiredHandle(shapeUrl1)).toBe(handle1)// Different shape should not have expired handleexpect(cache.getExpiredHandle(shapeUrl2)).toBe(null)})it(`should persist expired shapes to localStorage`,()=>{constshapeUrl=`https://example.com/v1/shape?table=test`consthandle=`test-handle`// Mark shape as expiredcache.markExpired(shapeUrl,handle)// Check that localStorage was updatedconststoredData=JSON.parse(localStorage.getItem(`electric_expired_shapes`)||`{}`)expect(storedData[shapeUrl]).toEqual({expiredHandle: handle,lastUsed: expect.any(Number),})})})
8. Cache Testing with Docker Container Access (test/cache.test.ts)
import{describe,expect,assert,inject}from'vitest'import{exec}from'child_process'import{setTimeoutassleep}from'node:timers/promises'import{testWithIssuesTable}from'./support/test-context'constmaxAge=1// secondsconststaleAge=3// secondsenumCacheStatus{MISS=`MISS`,EXPIRED=`EXPIRED`,STALE=`STALE`,HIT=`HIT`,}functiongetCacheStatus(res: Response): CacheStatus{returnres.headers.get(`X-Proxy-Cache`)asCacheStatus}exportasyncfunctionclearProxyCache({
proxyCacheContainerName,
proxyCachePath,}: {proxyCacheContainerName: stringproxyCachePath: string}): Promise<void>{returnnewPromise((res)=>exec(`docker exec ${proxyCacheContainerName} sh -c 'rm -rf ${proxyCachePath}'`,(_)=>res()))}constit=testWithIssuesTable.extend<{proxyCacheBaseUrl: stringclearCache: ()=>Promise<void>}>({proxyCacheBaseUrl: async({ clearCache },use)=>{awaitclearCache()use(inject(`proxyCacheBaseUrl`))},clearCache: async({},use)=>{use(async()=>awaitclearProxyCache({proxyCacheContainerName: inject(`proxyCacheContainerName`),proxyCachePath: inject(`proxyCachePath`),}))},})describe(`HTTP Proxy Cache`,()=>{it(`should get a short max-age cache-control header in live mode`,async({
insertIssues,
proxyCacheBaseUrl,
issuesTableUrl,})=>{// First request get initial requestconstinitialRes=awaitfetch(`${proxyCacheBaseUrl}/v1/shape?table=${issuesTableUrl}&offset=-1`,{})expect(initialRes.status).toBe(200)expect(getCacheStatus(initialRes)).toBe(CacheStatus.MISS)// add some data and follow with live requestawaitinsertIssues({title: `foo`})constsearchParams=newURLSearchParams({table: issuesTableUrl,handle: initialRes.headers.get(`electric-handle`)!,offset: initialRes.headers.get(`electric-offset`)!,live: `true`,})constliveRes=awaitfetch(`${proxyCacheBaseUrl}/v1/shape?${searchParams.toString()}`,{})expect(liveRes.status).toBe(200)expect(getCacheStatus(liveRes)).toBe(CacheStatus.MISS)// Second request gets a cached responseconstcachedRes=awaitfetch(`${proxyCacheBaseUrl}/v1/shape?${searchParams.toString()}`,{})expect(cachedRes.status).toBe(200)expect(getCacheStatus(cachedRes)).toBe(CacheStatus.HIT)})})
Summary of Patterns Used
Global Setup: Health check + schema creation + context injection
Fixtures: Chained .extend() for composable test setup
Parameterization: it.for() and describe.for() for configuration testing
Isolation: Unique table names with task ID + random suffix
Cleanup: Per-fixture teardown with error handling
Helpers: Utility functions for common operations
Typing: Module augmentation for context injection type safety
Serial Execution: fileParallelism: false for shared database safety
Electric E2E Test Setup - Complete Documentation Index
This directory contains comprehensive documentation about how Electric's TypeScript client implements e2e testing with Docker, Postgres, and Vitest. These patterns are ready to be replicated for TanStack DB.
Look at ACTUAL_CODE_EXCERPTS.md - Error handling patterns
Key Patterns at a Glance
1. Docker Composition
Postgres (port 54321) + Server (port 3000)
Uses tmpfs for speed, depends_on for ordering
2. Database Isolation
Electric DB (shared)
-> electric_test schema (created once)
-> Unique tables per test (task.id + random suffix)
3. Test Lifecycle
Global Setup (once per run)
├─ Health check
├─ Create test schema
└─ Provide context
Per-Test Fixtures (for each test)
├─ Create DB connection
├─ Create table
├─ Run test
└─ Cleanup (drop table, close connection)
4. Fixture Composition
testWithDb
extends testWithDb
extends testWithDb
(each level adds more fixtures)
Vitest: fileParallelism: false (MUST be disabled for shared DB)
Docker: tmpfs for Postgres data directory (speed)
Docker: depends_on: postgres for backend service (ordering)
Global Setup: Health check before proceeding (reliability)
Fixtures: Unique table names (isolation)
Cleanup: Don't throw in cleanup code (allows subsequent cleanup)
Replication Steps for TanStack DB
Create Docker Compose with Postgres + TanStack server
Implement global-setup.ts with health check
Create test context fixtures with test.extend()
Set fileParallelism: false in vitest.config.ts
Create unique table names per test with task.id
Add parameterized tests with it.for() if needed
Use AbortController for stream cleanup
Document connection defaults
Additional Resources
These documents reference:
Vitest 3.0+ (fixture system)
Node 'pg' library (PostgreSQL client)
Docker (container orchestration)
TypeScript (type safety)
Quick Lookup by Topic
Docker Setup: See QUICK_REFERENCE.md (Docker Compose section)
Database Isolation: See ELECTRIC_E2E_PATTERNS.md Section 2
Fixtures: See ACTUAL_CODE_EXCERPTS.md Section 3 or ELECTRIC_E2E_PATTERNS.md Section 3
Parameterization: See QUICK_REFERENCE.md (Parameterized Test section)
Health Check: See QUICK_REFERENCE.md (Health Check Pattern section)
Debugging: See QUICK_REFERENCE.md (Debugging Tips section)
Real Examples: See ACTUAL_CODE_EXCERPTS.md (all sections)
Architectural Overview: See ELECTRIC_E2E_PATTERNS.md Section 8
File Summary
File
Size
Purpose
Best For
ELECTRIC_E2E_PATTERNS.md
29KB
Comprehensive reference
Understanding architecture
QUICK_REFERENCE.md
7.7KB
Quick lookup
Finding specific patterns
ACTUAL_CODE_EXCERPTS.md
22KB
Real working code
Copy-paste implementations
ELECTRIC_E2E_INDEX.md
This file
Navigation guide
Finding what you need
Total Documentation Size
Approximately 60KB of comprehensive documentation covering:
1000+ lines of detailed explanations
400+ lines of real working code
Copy-paste templates for immediate use
Architecture diagrams and flow charts
Best practices and design patterns
Debugging tips and troubleshooting guides
Complete configuration reference
Generated from exploration of: ~/programs/electric/packages/typescript-client/test
All code excerpts are actual implementations from Electric's test suite.
Electric TypeScript Client E2E Test Infrastructure & Patterns
Overview
Electric's TypeScript client uses Vitest as the test framework with comprehensive e2e testing against Docker-containerized Postgres and Electric server. The approach provides excellent patterns for test isolation, parameterized testing, and database management.
Postgres uses tmpfs for /var/lib/postgresql/data for fast test execution
Postgres config file loaded from host for custom settings
Electric backend depends_on postgres for startup ordering
Insecure mode only for dev/test (NOT production)
Default credentials hardcoded: postgres:password
Default port mapping: Postgres 54321, Electric 3000
Connection Details for Tests
Tests connect via:
Host: localhost
Port: 54321 (Postgres)
User: postgres
Password: password
Database: electric
2. DATABASE ISOLATION: UNIQUE DATABASES PER TEST
Schema-Based Isolation Strategy
Rather than creating separate databases, Electric uses schema isolation:
Global Setup (test/support/global-setup.ts):
/** * Global setup for the test suite. Validates that our server is running, and creates and tears down a * special schema in Postgres to ensure clean slate between runs. */exportdefaultasyncfunction({ provide }: GlobalSetupContext){awaitwaitForElectric(url)constclient=makePgClient()awaitclient.connect()awaitclient.query(`CREATE SCHEMA IF NOT EXISTS electric_test`)provide(`baseUrl`,url)provide(`testPgSchema`,`electric_test`)provide(`proxyCacheBaseUrl`,proxyUrl)provide(`proxyCacheContainerName`,proxyCacheContainerName)provide(`proxyCachePath`,proxyCachePath)returnasync()=>{awaitclient.query(`DROP SCHEMA electric_test CASCADE`)awaitclient.end()}}
Each test gets a unique table name that includes task ID and random suffix:
issuesTableSql: async({ dbClient, task },use)=>{// Creates unique table name like: "issues for ABC123_a1b2c3"consttableName=`"issues for ${task.id}_${Math.random().toString(16).replace(`.`,`_`)}"`// Setup table for testawaitdbClient.query(` DROP TABLE IF EXISTS ${tableName}; CREATE TABLE ${tableName} ( id UUID PRIMARY KEY, title TEXT NOT NULL, priority INTEGER NOT NULL ); COMMENT ON TABLE ${tableName} IS 'Created for ${task.file?.name.replace(/'/g,`\``)??`unknown`} - ${task.name.replace(`'`,`\``)}'; `)awaituse(tableName)// Cleanup table after testawaitdbClient.query(`DROP TABLE ${tableName}`)},
Multi-Type Table Isolation - Same pattern for complex types:
tableSql: async({ dbClient, task },use)=>{consttableName=`"multitype table for ${task.id}_${Math.random().toString(16).replace(`.`,`_`)}"`awaitdbClient.query(` DROP TABLE IF EXISTS ${tableName}; DROP TYPE IF EXISTS mood; CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy'); CREATE TABLE ${tableName} ( txt VARCHAR, i2 INT2 PRIMARY KEY, ... )`)awaituse(tableName)// Full cleanup including custom typesawaitdbClient.query(` DROP TABLE ${tableName}; DROP TYPE IF EXISTS mood; `)},
Benefits of This Approach:
Single database connection simplifies setup/teardown
Tests can run serially without interference
Clear table names aid in debugging (include test file + test name + random suffix)
Comment on table shows which test created it
Schema-based rather than database-based reduces connection overhead
fileParallelism: false - Tests run serially (important for shared database)
globalSetup runs once before all tests
setupFiles runs before each test file
Level 2: Global Setup/Teardown
File: test/support/global-setup.ts
exportdefaultasyncfunction({ provide }: GlobalSetupContext){// SETUPawaitwaitForElectric(url)constclient=makePgClient()awaitclient.connect()awaitclient.query(`CREATE SCHEMA IF NOT EXISTS electric_test`)provide(`baseUrl`,url)provide(`testPgSchema`,`electric_test`)// Return cleanup function (runs once at end of all tests)returnasync()=>{awaitclient.query(`DROP SCHEMA electric_test CASCADE`)awaitclient.end()}}
Level 3: Fixture-Level Lifecycle (Per test)
File: test/support/test-context.ts
exportconsttestWithDbClient=test.extend<{dbClient: Clientaborter: AbortControllerbaseUrl: stringpgSchema: stringclearShape: ClearShapeFn}>({// Setup: Create connection, inject contextdbClient: async({},use)=>{constsearchOption=`-csearch_path=${inject(`testPgSchema`)}`constclient=makePgClient({options: searchOption})awaitclient.connect()// Pass client to testawaituse(client)// Cleanup: Close connectionawaitclient.end()},// Setup: Create abort controller for cancellationaborter: async({},use)=>{constcontroller=newAbortController()awaituse(controller)// Cleanup: Abort any pending operationscontroller.abort(`Test complete`)},// Inject provided valuesbaseUrl: async({},use)=>use(inject(`baseUrl`)),pgSchema: async({},use)=>use(inject(`testPgSchema`)),// Custom utility: Clear shape cachesclearShape: async({},use)=>{awaituse(async(table: string,options={})=>{constbaseUrl=inject(`baseUrl`)consturl=newURL(`${baseUrl}/v1/shape`)url.searchParams.set(`table`,table)if(options.handle){url.searchParams.set(SHAPE_HANDLE_QUERY_PARAM,options.handle)}constresp=awaitfetch(url.toString(),{method: `DELETE`})if(!resp.ok&&resp.status!==404){thrownewError(`Could not delete shape`)}})},})
import{beforeEach,afterEach,describe,expect,it,vi}from'vitest'describe(`ExpiredShapesCache`,()=>{letcache: ExpiredShapesCacheletaborter: AbortControllerbeforeEach(()=>{localStorage.clear()cache=newExpiredShapesCache()aborter=newAbortController()vi.clearAllMocks()})afterEach(()=>aborter.abort())it(`should mark shapes as expired`,()=>{// test code})})
4. MIGRATION HANDLING
Electric doesn't use traditional migrations in the test setup. Instead:
Global setup creates schema: CREATE SCHEMA IF NOT EXISTS electric_test
Per-test fixture creates tables: Each test creates its own tables with exact schema
Table structure is inline SQL: No migration files needed for tests
Custom types created per-test: Types like mood enum are created per test if needed
Example: Multi-type table setup with custom types
awaitdbClient.query(` DROP TABLE IF EXISTS ${tableName}; DROP TYPE IF EXISTS mood; DROP TYPE IF EXISTS complex; DROP DOMAIN IF EXISTS posint; CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy'); CREATE TYPE complex AS (r double precision, i double precision); CREATE DOMAIN posint AS integer CHECK (VALUE > 0); CREATE TABLE ${tableName} ( txt VARCHAR, i2 INT2 PRIMARY KEY, i4 INT4, i8 INT8, f8 FLOAT8, b BOOLEAN, json JSON, jsonb JSONB, ints INT8[], ... )`)// Cleanup includes all types and domainsawaitdbClient.query(` DROP TABLE ${tableName}; DROP TYPE IF EXISTS mood; DROP TYPE IF EXISTS complex; DROP DOMAIN IF EXISTS posint;`)
Why this approach:
Tests are self-contained and don't depend on external migration state
functionwaitForElectric(url: string): Promise<void>{returnnewPromise<void>((resolve,reject)=>{consttimeout=setTimeout(()=>reject(`Timed out waiting for Electric to be active`),10000)consttryHealth=async()=>fetch(`${url}/v1/health`).then(async(res): Promise<void>=>{if(!res.ok)returntryHealth()const{ status }=(awaitres.json())as{status: string}if(status!==`active`)returntryHealth()clearTimeout(timeout)resolve()}).catch((err)=>{clearTimeout(timeout)reject(err)})returntryHealth()})}
Key utilities:
Polls health endpoint until status is "active"
10 second timeout before failure
Recursive polling (retry until success or timeout)
constfetchAndSse=[{liveSse: false},{liveSse: true}]// Parameterized describe blockdescribe(`HTTP Sync`,()=>{// Test runs twice: once with liveSse=false, once with liveSse=trueit.for(fetchAndSse)(`should work with empty shape/table (liveSSE=$liveSse)`,async({ liveSse },{ issuesTableUrl, aborter })=>{constissueStream=newShapeStream({url: `${BASE_URL}/v1/shape`,params: {table: issuesTableUrl,},subscribe: false,signal: aborter.signal,
liveSse,// Parameter from the array})awaitnewPromise<void>((resolve,reject)=>{issueStream.subscribe((messages)=>{messages.forEach((message)=>{if(isChangeMessage(message)){shapeData.set(message.key,message.value)}if(isUpToDateMessage(message)){aborter.abort()returnresolve()}})},reject)})constvalues=[...shapeData.values()]expect(values).toHaveLength(0)})})
Parameterized Describe Block
File: test/client.test.ts
constfetchAndSse=[{liveSse: false},{liveSse: true}]describe.for(fetchAndSse)(`Shape (liveSSE=$liveSse)`,({ liveSse })=>{// All tests in this describe run twice (once per parameter set)it(`should sync an empty shape`,async({ issuesTableUrl, aborter })=>{conststart=Date.now()constshapeStream=newShapeStream({url: `${BASE_URL}/v1/shape`,params: {table: issuesTableUrl,},signal: aborter.signal,
liveSse,// Available from describe scope})constshape=newShape(shapeStream)expect(awaitshape.value).toEqual(newMap())expect(awaitshape.rows).toEqual([])expect(shape.lastSyncedAt()).toBeGreaterThanOrEqual(start)})it(`should notify with the initial value`,async({
issuesTableUrl,
insertIssues,
aborter,})=>{const[id]=awaitinsertIssues({title: `test title`})constshapeStream=newShapeStream({url: `${BASE_URL}/v1/shape`,params: {table: issuesTableUrl,},signal: aborter.signal,
liveSse,})constshape=newShape(shapeStream)constrows=awaitnewPromise((resolve)=>{shape.subscribe(({ rows })=>resolve(rows))})expect(rows).toEqual([{id: id,title: `test title`,priority: 10}])})})
Parameterized Multi-Type Tests
File: test/integration.test.ts
mit.for(fetchAndSse)(`should parse incoming data (liveSSE=$liveSse)`,async({ liveSse },{ dbClient, aborter, tableSql, tableUrl })=>{// Create a table with data we want to be parsedawaitdbClient.query(` INSERT INTO ${tableSql} (txt, i2, i4, i8, f8, b, json, jsonb, ints, ints2, int4s, bools, moods, moods2, complexes, posints, jsons, txts, value, doubles) VALUES ( 'test', 1, 2147483647, 9223372036854775807, 4.5, TRUE, '{"foo": "bar"}', '{"foo": "bar"}', '{1,2,3}', $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 ) `,[[[1,2,3],[4,5,6]],[1,2,3],[true,false,true],[`sad`,`ok`,`happy`],[[`sad`,`ok`],[`ok`,`happy`],],[`(1.1, 2.2)`,`(3.3, 4.4)`],[5,9,2],[[`foo`,`bar`],[`baz`,`qux`],],{something: `else`},[1.1,2.2,3.3],])constissueStream=newShapeStream({url: `${BASE_URL}/v1/shape`,params: {table: tableUrl,},signal: aborter.signal,
liveSse,})// ... rest of test})
Parameterization Key Points:
it.for(params) runs test multiple times with each param set
describe.for(params) runs all tests in describe block multiple times
Parameters are destructured in callback: async ({ liveSse }, { fixtures })
Test name uses template string interpolation: (liveSSE=$liveSse)
Works with custom fixtures (like mit for multi-type tables)
7. REAL-WORLD USAGE EXAMPLES
Complete Integration Test Example
import{describe,expect,inject}from'vitest'import{testWithIssuesTableasit}from'./support/test-context'import{ShapeStream}from'../src'constBASE_URL=inject(`baseUrl`)constfetchAndSse=[{liveSse: false},{liveSse: true}]describe(`HTTP Sync`,()=>{// Basic test with auto-cleanup via fixturesit(`sanity check`,async({ dbClient, issuesTableSql })=>{constresult=awaitdbClient.query(`SELECT * FROM ${issuesTableSql}`)expect(result.rows).toEqual([])})// Parameterized test with initial datait.for(fetchAndSse)(`should get initial data (liveSSE=$liveSse)`,async({ liveSse },{ insertIssues, issuesTableUrl, aborter })=>{// Setup: Insert dataconstuuid=uuidv4()awaitinsertIssues({id: uuid,title: `foo + ${uuid}`})// Execute: Create stream and subscribeconstshapeData=newMap()constissueStream=newShapeStream({url: `${BASE_URL}/v1/shape`,params: {table: issuesTableUrl,},signal: aborter.signal,
liveSse,})// Wait for dataawaitnewPromise<void>((resolve)=>{issueStream.subscribe((messages)=>{messages.forEach((message)=>{if(isChangeMessage(message)){shapeData.set(message.key,message.value)}if(isUpToDateMessage(message)){aborter.abort()returnresolve()}})})})// Assertconstvalues=[...shapeData.values()]expect(values).toMatchObject([{title: `foo + ${uuid}`}])// Cleanup: Automatic! Fixtures handle:// - Table drops (issuesTableSql fixture)// - Shape cache clears (issuesTableUrl fixture)// - Connection closes (dbClient fixture)// - Stream aborts (aborter fixture)})})
Cache Testing with Proxy Container Access
File: test/cache.test.ts
constit=testWithIssuesTable.extend<{proxyCacheBaseUrl: stringclearCache: ()=>Promise<void>}>({proxyCacheBaseUrl: async({ clearCache },use)=>{awaitclearCache()use(inject(`proxyCacheBaseUrl`))},clearCache: async({},use)=>{use(async()=>awaitclearProxyCache({proxyCacheContainerName: inject(`proxyCacheContainerName`),proxyCachePath: inject(`proxyCachePath`),}))},})exportasyncfunctionclearProxyCache({
proxyCacheContainerName,
proxyCachePath,}: {proxyCacheContainerName: stringproxyCachePath: string}): Promise<void>{returnnewPromise((res)=>exec(`docker exec ${proxyCacheContainerName} sh -c 'rm -rf ${proxyCachePath}'`,(_)=>res()))}describe(`HTTP Proxy Cache`,()=>{it(`should get a short max-age cache-control header in live mode`,async({
insertIssues,
proxyCacheBaseUrl,
issuesTableUrl,})=>{// First request gets initial requestconstinitialRes=awaitfetch(`${proxyCacheBaseUrl}/v1/shape?table=${issuesTableUrl}&offset=-1`,{})expect(initialRes.status).toBe(200)expect(getCacheStatus(initialRes)).toBe(CacheStatus.MISS)// Add some data and follow with live requestawaitinsertIssues({title: `foo`})constsearchParams=newURLSearchParams({table: issuesTableUrl,handle: initialRes.headers.get(`electric-handle`)!,offset: initialRes.headers.get(`electric-offset`)!,live: `true`,})constliveRes=awaitfetch(`${proxyCacheBaseUrl}/v1/shape?${searchParams.toString()}`,{})expect(liveRes.status).toBe(200)expect(getCacheStatus(liveRes)).toBe(CacheStatus.MISS)// Second request gets cached responseconstcachedRes=awaitfetch(`${proxyCacheBaseUrl}/v1/shape?${searchParams.toString()}`,{})expect(cachedRes.status).toBe(200)expect(getCacheStatus(cachedRes)).toBe(CacheStatus.HIT)})})
8. KEY ARCHITECTURAL PATTERNS
Fixture Composition Pattern
The test fixtures form a chain where each level builds on the previous:
testWithIssuesTable adds: issuesTableSql, issuesTableUrl, issuesTableKey, insertIssues, deleteIssue, updateIssue, etc.
Custom test extends can further extend (like proxyCacheBaseUrl, clearCache)
Lifecycle Management Pattern
SETUP USE TEARDOWN
├─ Create client ────────────→ Test runs ────────────────→ End connection
├─ Create AbortController ────→ Signal available ────────→ Abort
├─ Create table ──────────────→ Query available ────────→ Drop table
├─ Create helpers ────────────→ Methods available ───────→ (auto-cleanup)
└─ Inject context ────────────→ Injected values ready ──→ Cleanup
Test Isolation Pattern
Global Schema: electric_test (created once, dropped once)
│
├─ Test 1: creates "issues for ABC123_xyz" table
│ ├─ Test runs
│ └─ DROP table
│
├─ Test 2: creates "issues for DEF456_abc" table
│ ├─ Test runs
│ └─ DROP table
│
└─ Test 3: creates "issues for GHI789_def" table
├─ Test runs
└─ DROP table
Each test has:
Unique table names (task.id + random suffix)
Isolated data (no cross-test pollution)
Full cleanup (drop table + clear shapes + close connections)
Comments showing which test created the table (helpful for debugging)
Error Resilience Pattern
try{awaitclearShape(urlAppropriateTable)}catch(_){// ignore - clearShape has its own logging// we don't want to interrupt cleanup}
Cleanup code doesn't throw, allowing subsequent cleanup steps to run even if one fails.
9. TESTING BEST PRACTICES FROM ELECTRIC
Serial Execution: fileParallelism: false prevents concurrency issues with shared database
Health Checks: Wait for Electric to be "active" before running tests
Unique Table Names: Use task ID + random suffix to prevent collisions
Full Cleanup: Always drop tables, clear caches, close connections
Fixture Composition: Build complex fixtures from simpler ones
Context Injection: Use Vitest's provide()/inject() for test-wide values
Abort Controllers: Use AbortSignal for clean stream shutdown
Parameterized Tests: Use it.for() and describe.for() for testing multiple configurations
Helpful Comments: Add SQL comments showing which test created tables
Graceful Degradation: Catch errors in cleanup code to avoid masking test failures
10. REPLICATION CHECKLIST FOR TANSTACK DB
When implementing similar e2e testing for TanStack DB:
Create Docker Compose with Postgres + server service
Implement global setup that:
Waits for server health endpoint
Creates shared test schema/database
Provides context values (baseUrl, testDb, etc.)
Returns cleanup function
Create test fixtures extending from base test:
Database client fixture
AbortController fixture
Table creation fixtures with unique names
Helper fixtures (insert, delete, update, query)
Set fileParallelism: false in vitest.config.ts
Use .extend() to compose fixtures
Add parameterized tests with it.for() and describe.for()
importtype{GlobalSetupContext}from'vitest/node'import{makePgClient}from'./test-helpers'exportdefaultasyncfunction({ provide }: GlobalSetupContext){// Health checkawaitwaitForServer(process.env.SERVER_URL??`http://localhost:3000`)// Setupconstclient=makePgClient()awaitclient.connect()awaitclient.query(`CREATE SCHEMA IF NOT EXISTS test_schema`)provide(`baseUrl`,process.env.SERVER_URL??`http://localhost:3000`)provide(`testSchema`,`test_schema`)// Cleanup functionreturnasync()=>{awaitclient.query(`DROP SCHEMA test_schema CASCADE`)awaitclient.end()}}functionwaitForServer(url: string): Promise<void>{returnnewPromise<void>((resolve,reject)=>{consttimeout=setTimeout(()=>reject(`Timeout`),10000)consttryHealth=async()=>fetch(`${url}/health`).then(async(res)=>{if(!res.ok)returntryHealth()clearTimeout(timeout)resolve()}).catch(()=>tryHealth())tryHealth()})}
3. Test Context Fixtures
import{test}from'vitest'import{Client}from'pg'consttestWithDb=test.extend<{dbClient: ClienttableName: string}>({dbClient: async({},use)=>{constclient=newClient({host: `localhost`,port: 54321,user: `postgres`,password: `password`,database: `electric`,})awaitclient.connect()awaituse(client)awaitclient.end()},tableName: async({ dbClient, task },use)=>{constname=`test_${task.id}_${Math.random().toString(16).slice(2)}`awaitdbClient.query(` CREATE TABLE ${name} ( id UUID PRIMARY KEY, data TEXT ) `)awaituse(name)awaitdbClient.query(`DROP TABLE ${name}`)},})export{testWithDbasit}
4. Parameterized Test
constconfigs=[{mode: 'fetch'},{mode: 'stream'},]describe.for(configs)(`Data sync (mode=$mode)`,({ mode })=>{it(`should sync data`,async({ dbClient, tableName })=>{// Test code using mode parameterexpect(mode).toBe('fetch')})})