Testing Philosophy
Aya follows a pragmatic testing approach :
Business logic MUST have tests - Pure functions, services, and domain logic
Adapters should have integration tests - Database, HTTP clients, external APIs
UI components use snapshot tests - Anti-regression testing for React components
Test code is production code - Apply the same quality standards
All tests must pass before committing. The make ok command runs all tests in CI mode.
Frontend Testing (Deno)
Snapshot Testing with Deno
Aya uses Deno’s native test runner with @std/testing/snapshot for anti-regression testing.
Test File Structure
/// < reference lib = "deno.ns" />
import { assertSnapshot } from "@std/testing/snapshot" ;
import { myFunction } from "./my-utils.ts" ;
Deno . test ( "myFunction - returns formatted locale string" , async ( t ) => {
const result = myFunction ( "en-US" );
await assertSnapshot ( t , result );
});
Deno . test ( "myFunction - handles invalid locale" , async ( t ) => {
const result = myFunction ( "invalid" );
await assertSnapshot ( t , result );
});
Why /// <reference lib="deno.ns" />?
The project’s tsconfig.json targets Vite/React, not Deno. This directive enables Deno types for test files.
Running Tests
cd apps/webclient
# Run tests (writes snapshots)
deno task test
# Run tests in CI mode (read-only, fails if snapshots don't match)
deno task test:ci
# Update snapshots after intentional changes
deno task test:update
Test commands in package.json:
{
"test" : "deno test --no-check --allow-read --allow-write=. src/" ,
"test:update" : "deno test --no-check --allow-read --allow-write=. -- --update src/" ,
"test:ci" : "deno test --no-check --allow-read src/"
}
make ok uses test:ci (read-only mode). Never commit snapshot mismatches. Update snapshots explicitly with deno task test:update.
When to Update Snapshots
Make Intentional Change
Change function behavior, update UI component, or fix a bug.
Verify Test Failure
Should fail with snapshot mismatch.
Review Diff
Manually verify the diff shows your intended changes (not accidental breakage).
Commit Updated Snapshots
git add .
git commit -m "test: update snapshots for locale formatting"
Testing Pure Functions
Extract pure logic for testability (see Code Style: Pure Function Extraction ).
locale-utils.ts (Testable)
locale-utils.test.ts
export const SUPPORTED_LOCALES = [ "en" , "tr" , "fr" , "de" ] as const ;
export function isValidLocale ( locale : string ) : boolean {
return SUPPORTED_LOCALES . includes ( locale as any );
}
export function normalizeLocale ( locale : string ) : string {
return locale . toLowerCase (). trim ();
}
Key principles:
Test files co-located with source: foo.ts → foo.test.ts
Use descriptive test names: "functionName - specific behavior"
Test edge cases: empty strings, null, undefined, boundary values
Use assertions from @std/assert: assertEquals, assertThrows, etc.
Testing React Components (Snapshot Tests)
Snapshot tests verify component output doesn’t change unexpectedly.
/// < reference lib = "deno.ns" />
import { assertSnapshot } from "@std/testing/snapshot" ;
import { render } from "@testing-library/react" ;
import { ProfileCard } from "./profile-card.tsx" ;
Deno . test ( "ProfileCard - renders with all props" , async ( t ) => {
const { container } = render (
< ProfileCard
profile = {{
id : "123" ,
slug : "test-user" ,
title : "Test User" ,
bio : "Test bio" ,
}}
/> ,
);
await assertSnapshot ( t , container . innerHTML );
});
Deno . test ( "ProfileCard - renders without bio" , async ( t ) => {
const { container } = render (
< ProfileCard
profile = {{
id : "123" ,
slug : "test-user" ,
title : "Test User" ,
bio : null ,
}}
/> ,
);
await assertSnapshot ( t , container . innerHTML );
});
Best practices:
Test different prop combinations
Test conditional rendering (null checks, feature flags)
Focus on output, not implementation details
Keep snapshots small and focused
Backend Testing (Go)
Table-Driven Tests
Use table-driven tests for comprehensive coverage with minimal boilerplate.
package user
import " testing "
func TestCalculateAge ( t * testing . T ) {
t . Parallel ()
cases := [] struct {
name string
birthYear int
expected int
}{
{ "born in 2000" , 2000 , 24 },
{ "born in 1990" , 1990 , 34 },
{ "born in 2024" , 2024 , 0 },
}
for _ , tc := range cases {
tc := tc // Capture range variable
t . Run ( tc . name , func ( t * testing . T ) {
t . Parallel ()
result := CalculateAge ( tc . birthYear )
if result != tc . expected {
t . Errorf ( "got %d , want %d " , result , tc . expected )
}
})
}
}
Key elements:
t.Parallel() - Run tests concurrently
tc := tc - Capture range variable for parallel execution
t.Run() - Subtests with descriptive names
Clear error messages: "got X, want Y"
Testing Business Logic with Mocks
Business logic depends on interfaces, not concrete types. Use mocks for dependencies.
user_service.go
user_service_test.go
package user
type Repository interface {
FindByID ( ctx context . Context , id string ) ( * User , error )
}
type Service struct {
repo Repository
}
func NewService ( repo Repository ) * Service {
return & Service { repo : repo }
}
func ( s * Service ) GetUser ( ctx context . Context , id string ) ( * User , error ) {
if id == "" {
return nil , ErrInvalidID
}
return s . repo . FindByID ( ctx , id )
}
Mock guidelines:
Implement only the interface methods needed for the test
Use simple in-memory data structures (maps, slices)
Consider using mockery for complex interfaces
Integration Tests for Adapters
Adapters (database, HTTP clients, Redis) should have integration tests with real dependencies.
package adapters
import (
" context "
" testing "
" github.com/testcontainers/testcontainers-go "
" github.com/testcontainers/testcontainers-go/postgres "
)
func TestUserRepository_Integration ( t * testing . T ) {
if testing . Short () {
t . Skip ( "skipping integration test" )
}
ctx := context . Background ()
// Start PostgreSQL container
pgContainer , err := postgres . Run ( ctx , "postgres:16" )
if err != nil {
t . Fatalf ( "failed to start postgres: %v " , err )
}
defer pgContainer . Terminate ( ctx )
connStr , _ := pgContainer . ConnectionString ( ctx )
// Test repository with real database
repo := NewUserRepository ( connStr )
user := & User { ID : "123" , Name : "Test" }
err = repo . Create ( ctx , user )
if err != nil {
t . Fatalf ( "failed to create user: %v " , err )
}
found , err := repo . FindByID ( ctx , "123" )
if err != nil {
t . Fatalf ( "failed to find user: %v " , err )
}
if found . Name != "Test" {
t . Errorf ( "got name %s , want Test" , found . Name )
}
}
Integration test practices:
Skip with testing.Short() for fast unit test runs
Use testcontainers for isolated environments
Clean up resources with defer
Test real interactions, not mocked behavior
Running Go Tests
cd apps/services
# Run all tests with race detection
make test
# Equivalent to: go test -failfast -race -count 1 ./...
# Run tests with coverage
make test-cov
# View coverage in browser
make test-view-html
# Run only unit tests (skip integration tests)
go test -short ./...
# Run specific package
go test ./pkg/api/business/user/
# Run specific test
go test -run TestService_GetUser ./pkg/api/business/user/
Test Organization
Frontend (Deno)
apps/webclient/src/
├── lib/
│ ├── locale-utils.ts # Pure functions
│ ├── locale-utils.test.ts # Unit tests
│ ├── __snapshots__/ # Generated snapshots
│ │ └── locale-utils.test.ts.snap
│ └── config.ts # Uses import.meta.env (not tested)
└── components/
├── profile-card.tsx
└── profile-card.test.tsx # Snapshot tests
Test file naming : *.test.ts for Deno tests
Backend (Go)
apps/services/pkg/api/
├── business/
│ └── user/
│ ├── service.go # Business logic
│ └── service_test.go # Unit tests with mocks
└── adapters/
├── postgres/
│ ├── user_repository.go # Adapter
│ └── user_repository_test.go # Integration tests
└── appcontext/
└── appcontext.go # Composition root
Test file naming : *_test.go (Go convention)
Testing Best Practices
Test Naming
Use descriptive names that explain what’s being tested:
Good - TypeScript/Deno
Good - Go
Bad - Vague Names
Deno . test ( "isValidLocale - returns true for supported locale" , () => {});
Deno . test ( "isValidLocale - returns false for empty string" , () => {});
Deno . test ( "ProfileCard - renders bio when present" , async ( t ) => {});
Deno . test ( "ProfileCard - hides bio when null" , async ( t ) => {});
Test Independence
Each test should be independent - no shared state between tests.
// ✅ Independent tests
Deno . test ( "test 1" , () => {
const user = { id: "123" , name: "Test" }; // Fresh data
assertEquals ( processUser ( user ). name , "Test" );
});
Deno . test ( "test 2" , () => {
const user = { id: "456" , name: "Other" }; // Fresh data
assertEquals ( processUser ( user ). name , "Other" );
});
// ❌ Shared state (fragile)
const sharedUser = { id: "123" , name: "Test" };
Deno . test ( "test 1" , () => {
sharedUser . name = "Modified" ; // Mutates shared state
});
Deno . test ( "test 2" , () => {
// This test's behavior depends on test 1's execution
assertEquals ( sharedUser . name , "Test" ); // Fails if test 1 runs first
});
Explicit Assertions
Use explicit equality checks (follows project convention):
// ✅ Explicit checks
assertEquals ( result , null );
assertEquals ( items . length , 0 );
assertEquals ( value !== undefined , true );
// ❌ Truthy/falsy (avoid per project standards)
assert ( ! result ); // Ambiguous
assert ( ! items . length ); // Fails for 0
Test Coverage
Aim for meaningful coverage, not 100%:
High priority : Business logic, data transformations, validation
Medium priority : Adapters, HTTP handlers, utilities
Low priority : Simple getters/setters, generated code
# Check backend coverage
cd apps/services
make test-cov
# View report
make test-view-html
Common Testing Patterns
Always test error paths: Deno . test ( "parseUser - throws on invalid JSON" , () => {
assertThrows (
() => parseUser ( "invalid json" ),
Error ,
"Invalid JSON" ,
);
});
Deno . test ( "parseUser - throws on missing required field" , () => {
assertThrows (
() => parseUser ( '{"name": "Test"}' ), // Missing "id"
Error ,
"Missing required field: id" ,
);
});
Use async/await consistently: Deno . test ( "fetchUser - returns user data" , async () => {
const user = await fetchUser ( "123" );
assertEquals ( user . id , "123" );
});
Deno . test ( "fetchUser - handles network error" , async () => {
await assertRejects (
async () => await fetchUser ( "invalid" ),
Error ,
"Network error" ,
);
});
Testing with Context (Go)
Always pass context.Context: func TestService_WithContext ( t * testing . T ) {
ctx := context . Background ()
// Test cancellation
cancelCtx , cancel := context . WithCancel ( ctx )
cancel () // Cancel immediately
_ , err := service . GetUser ( cancelCtx , "123" )
if ! errors . Is ( err , context . Canceled ) {
t . Errorf ( "expected context.Canceled, got %v " , err )
}
}
CI/CD Integration
Tests run automatically on every push:
# .github/workflows/test.yml (example)
name : Test
on : [ push , pull_request ]
jobs :
frontend :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : denoland/setup-deno@v1
- run : cd apps/webclient && deno task test:ci
backend :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-go@v5
- run : cd apps/services && make test
Local CI simulation:
# Run exactly what CI runs
make ok
Troubleshooting
Symptom : deno task test:ci fails with snapshot mismatch.Diagnosis :
Review the diff in terminal output
Check if change was intentional or a bug
Fix :
If intentional: deno task test:update
If bug: Fix the code, don’t update snapshots
Symptom : make test fails with race detector warnings.Diagnosis : Concurrent access to shared data without synchronization.Fix :
Use channels for communication between goroutines
Protect shared state with sync.Mutex
Avoid global mutable state
Symptom : Tests hang or timeout.Diagnosis : Blocking operations without timeout.Fix :// Add timeout to Deno tests
Deno . test ({
name: "slow operation" ,
async fn () {
// Test code
},
sanitizeOps: false ,
sanitizeResources: false ,
});
// Add timeout to Go tests
func TestSlow ( t * testing . T ) {
ctx , cancel := context . WithTimeout ( context . Background (), 5 * time . Second )
defer cancel ()
// Use ctx in test
}
Further Reading