Testing
GospeLib uses three testing frameworks across its polyglot codebase: Vitest for TypeScript, pytest for Python, and Go's built-in test runner for Go services. This guide covers how to run existing tests and write new ones.
Running Tests
Run everything
# All tests across all languages
pnpm test
# Only tests affected by your changes
pnpm nx affected -t test
By language
# TypeScript (Vitest)
pnpm nx affected -t test --exclude=content,ai,ingest,gateway,auth,billing,notifications
# Python
cd services/content && uv run pytest
cd services/ai && uv run pytest
cd services/ingest && uv run pytest
# Go
cd services/gateway && go test ./... -race
cd services/auth && go test ./... -race
cd services/billing && go test ./... -race
cd services/notifications && go test ./... -race
By service
Use the VS Code tasks for convenience:
- Test: Python (Content) —
cd services/content && uv run pytest - Test: Python (AI) —
cd services/ai && uv run pytest - Test: Python (Ingest) —
cd services/ingest && uv run pytest - Test: Go (Gateway) —
cd services/gateway && go test ./... -race
Test File Conventions
| Language | File Pattern | Location |
|---|---|---|
| TypeScript | *.test.ts / *.test.tsx | Co-located with source or __tests__/ |
| Go | *_test.go | Co-located with source |
| Python | test_*.py | tests/unit/, tests/integration/, or tests/fixtures/ |
Writing TypeScript Tests (Vitest)
Tests use Vitest with shared helpers from @gospelib/testing.
// packages/ui/src/components/PassageCard/PassageCard.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { PassageCard } from './PassageCard';
describe('PassageCard', () => {
it('renders the passage reference', () => {
render(<PassageCard reference="Genesis 1:1" text="In the beginning..." />);
expect(screen.getByText('Genesis 1:1')).toBeDefined();
});
});
Each app and package that needs tests has a vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom', // or 'node' for non-UI packages
},
});
Writing Go Tests
Use table-driven tests with t.Run() subtests:
// services/gateway/internal/config/config_test.go
package config_test
import (
"os"
"testing"
"github.com/gospelib/main/services/gateway/internal/config"
)
func TestLoad(t *testing.T) {
tests := []struct {
name string
envKey string
envValue string
wantPort string
}{
{"default port", "", "", "8080"},
{"custom port", "PORT", "9090", "9090"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envKey != "" {
os.Setenv(tt.envKey, tt.envValue)
defer os.Unsetenv(tt.envKey)
}
cfg := config.Load()
if cfg.Port != tt.wantPort {
t.Errorf("got port %s, want %s", cfg.Port, tt.wantPort)
}
})
}
}
CI runs all Go tests with the -race flag to detect data races.
Writing Python Tests (pytest)
Directory structure
services/<name>/tests/
├── __init__.py
├── conftest.py # Shared fixtures
├── fixtures/ # Test data files
├── unit/ # Fast, isolated tests
├── integration/ # Require external services
└── smoke_test.py # End-to-end pipeline smoke test
Unit test example
# services/content/tests/unit/test_models.py
import pytest
from gospelib_content.models.passage import PassageResponse
def test_passage_response_validates():
response = PassageResponse(
id="gen.1.1",
book_id="gen",
chapter=1,
verse=1,
text="In the beginning God created the heaven and the earth.",
)
assert response.id == "gen.1.1"
def test_passage_response_rejects_extra_fields():
with pytest.raises(ValueError):
PassageResponse(
id="gen.1.1",
book_id="gen",
chapter=1,
verse=1,
text="...",
unknown_field="bad",
)
Integration test with testcontainers
# services/ingest/tests/integration/test_pipeline.py
import pytest
from testcontainers.redis import RedisContainer
@pytest.fixture(scope="module")
def falkordb():
with RedisContainer(image="falkordb/falkordb:latest") as container:
yield container.get_connection_url()
def test_lexicon_pipeline_writes_nodes(falkordb):
# Connect to the test FalkorDB instance and verify node creation
...
Fixtures in conftest.py
# services/content/tests/conftest.py
import pytest
@pytest.fixture
def sample_passage():
return {
"id": "gen.1.1",
"book_id": "gen",
"chapter": 1,
"verse": 1,
"text": "In the beginning God created the heaven and the earth.",
}
Test Pyramid
╱ E2E Tests ╲ Few — critical user journeys only
╱──────────────╲
╱ Integration ╲ Focused — service boundary tests
╱──────────────────╲
╱ Unit Tests ╲ Many — fast, isolated, no I/O
╱──────────────────────╲
- Unit tests are fast and isolated — no network, no database, no filesystem
- Integration tests spin up real dependencies (FalkorDB, PostgreSQL) via testcontainers
- E2E tests exercise critical user flows through the full stack
CI Integration
The CI pipeline runs tests in parallel by language:
| Job | Command | Services |
|---|---|---|
| test-js | nx affected -t test --parallel=3 | Vitest for all TS projects |
| test-python | uv run pytest per service | content, ai, ingest (with FalkorDB + PostgreSQL containers) |
| test-go | go test ./... -race per service | gateway, auth, billing, notifications |
Troubleshooting
Tests pass locally but fail in CI
- Check that environment variables are set correctly in the CI workflow
- Python tests that need FalkorDB or PostgreSQL must run in the
test-pythonjob, which spins up service containers
Go race detector failures
The -race flag catches data races. If a test fails with a race condition, fix the concurrent access — do not remove the flag.
Vitest test isolation
Each test file runs in its own context. If tests depend on shared state, use beforeEach / afterEach to reset.