Skip to main content

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

LanguageFile PatternLocation
TypeScript*.test.ts / *.test.tsxCo-located with source or __tests__/
Go*_test.goCo-located with source
Pythontest_*.pytests/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:

JobCommandServices
test-jsnx affected -t test --parallel=3Vitest for all TS projects
test-pythonuv run pytest per servicecontent, ai, ingest (with FalkorDB + PostgreSQL containers)
test-gogo test ./... -race per servicegateway, 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-python job, 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.