fi-fhir docs
Testing Philosophy
Testing Guidelines
This guide covers testing practices for fi-fhir development.
Testing Philosophy
- Table-driven tests for comprehensive coverage
- Real data samples over mock data
- Warnings over errors - test that parsers handle edge cases gracefully
- Golden files for output validation
Test Organization
fi-fhir/
├── internal/
│ └── parser/
│ └── hl7v2/
│ ├── parser.go
│ └── parser_test.go # Unit tests
├── pkg/
│ └── events/
│ ├── events.go
│ └── events_test.go # Unit tests
├── testdata/ # Shared test fixtures
│ ├── hl7v2/
│ │ ├── adt_a01_sample.hl7
│ │ └── oru_r01_sample.hl7
│ └── edi/
│ └── 837p_sample.x12
└── test/
└── e2e/
├── e2e_test.go # E2E tests
└── integration_test.go # Integration tests
Unit Tests
Table-Driven Tests
func TestParser_ParseADT(t *testing.T) {
tests := []struct {
name string
input string
want events.EventType
wantMRN string
wantErr bool
}{
{
name: "ADT_A01 admit",
input: loadTestData("adt_a01_basic.hl7"),
want: events.EventPatientAdmit,
wantMRN: "MRN12345",
},
{
name: "ADT_A03 discharge",
input: loadTestData("adt_a03_basic.hl7"),
want: events.EventPatientDischarge,
wantMRN: "MRN12345",
},
{
name: "missing PID segment",
input: "MSH|^~\\&|...\nPV1|...",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := NewParser(defaultProfile())
events, err := p.Parse([]byte(tt.input))
if (err != nil) != tt.wantErr {
t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
if len(events) == 0 {
t.Fatal("expected at least one event")
}
if events[0].Type() != tt.want {
t.Errorf("Type() = %v, want %v", events[0].Type(), tt.want)
}
// More assertions...
}
})
}
}
Test Helpers
// testdata.go
package hl7v2_test
import (
"os"
"path/filepath"
"testing"
)
func loadTestData(t *testing.T, filename string) []byte {
t.Helper()
path := filepath.Join("..", "..", "..", "testdata", "hl7v2", filename)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to load test data %s: %v", filename, err)
}
return data
}
func defaultProfile() *profile.Profile {
return &profile.Profile{
ID: "test",
Name: "Test Profile",
HL7v2: profile.HL7v2Config{
DefaultVersion: "2.5",
},
}
}
Testing Warnings
func TestParser_Warnings(t *testing.T) {
// Message with missing optional segment
input := loadTestData(t, "adt_missing_nk1.hl7")
// Profile tolerates missing NK1
profile := &profile.Profile{
HL7v2: profile.HL7v2Config{
Tolerate: profile.TolerateConfig{
MissingSegments: []string{"NK1"},
},
},
}
p := NewParser(profile)
events, err := p.Parse(input)
// Should NOT error
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have warning
if len(events) == 0 {
t.Fatal("expected event")
}
warnings := events[0].Meta().ParseWarnings
if len(warnings) == 0 {
t.Error("expected warning for missing NK1")
}
found := false
for _, w := range warnings {
if w.Code == "MISSING_NK1" {
found = true
break
}
}
if !found {
t.Error("expected MISSING_NK1 warning")
}
}
Golden File Tests
For complex output validation:
func TestParser_GoldenOutput(t *testing.T) {
input := loadTestData(t, "complex_message.hl7")
p := NewParser(defaultProfile())
events, err := p.Parse(input)
if err != nil {
t.Fatal(err)
}
got, _ := json.MarshalIndent(events, "", " ")
goldenPath := "testdata/golden/complex_message.json"
if *update {
// Run with -update to regenerate golden files
os.WriteFile(goldenPath, got, 0644)
return
}
want, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("failed to read golden file: %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("output differs from golden file\ngot:\n%s\nwant:\n%s", got, want)
}
}
var update = flag.Bool("update", false, "update golden files")
Update golden files with:
go test -update ./internal/parser/hl7v2/...
Integration Tests
Integration tests use real external services via Docker.
Setup
// integration_test.go
//go:build integration
package e2e
import (
"context"
"testing"
"github.com/testcontainers/testcontainers-go"
)
func TestFHIRIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
// Start FHIR server container
ctx := context.Background()
fhirContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "hapiproject/hapi:latest",
ExposedPorts: []string{"8080/tcp"},
WaitingFor: wait.ForHTTP("/fhir/metadata"),
},
Started: true,
})
if err != nil {
t.Fatal(err)
}
defer fhirContainer.Terminate(ctx)
endpoint, _ := fhirContainer.Endpoint(ctx, "http")
// Run test against real FHIR server
// ...
}
Running Integration Tests
# Start dependencies
docker-compose up -d
# Run integration tests
go test -tags=integration ./test/e2e/...
# Or use make target
make test-integration
E2E Tests
End-to-end tests verify the full CLI workflow:
// e2e_test.go
package e2e
import (
"bytes"
"os/exec"
"testing"
)
func TestCLI_Parse(t *testing.T) {
cmd := exec.Command("./bin/fi-fhir", "parse",
"--format", "hl7v2",
"--pretty",
"testdata/hl7v2/adt_a01_sample.hl7",
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
t.Fatalf("command failed: %v\nstderr: %s", err, stderr.String())
}
output := stdout.String()
// Verify output contains expected data
if !strings.Contains(output, `"type": "patient_admit"`) {
t.Errorf("output missing event type:\n%s", output)
}
}
func TestCLI_WorkflowDryRun(t *testing.T) {
// Parse message, pipe to workflow
parseCmd := exec.Command("./bin/fi-fhir", "parse",
"--format", "hl7v2",
"testdata/hl7v2/adt_a01_sample.hl7",
)
workflowCmd := exec.Command("./bin/fi-fhir", "workflow", "run",
"--dry-run",
"--config", "testdata/workflows/test_workflow.yaml",
)
// Pipe parse output to workflow input
pipe, _ := parseCmd.StdoutPipe()
workflowCmd.Stdin = pipe
var stdout bytes.Buffer
workflowCmd.Stdout = &stdout
parseCmd.Start()
workflowCmd.Start()
parseCmd.Wait()
workflowCmd.Wait()
// Verify dry-run output
// ...
}
Benchmarks
func BenchmarkParser_Parse(b *testing.B) {
input := loadBenchData("large_message.hl7")
profile := defaultProfile()
b.ResetTimer()
for i := 0; i < b.N; i++ {
p := NewParser(profile)
_, err := p.Parse(input)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkParser_ParseParallel(b *testing.B) {
input := loadBenchData("large_message.hl7")
profile := defaultProfile()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
p := NewParser(profile)
_, err := p.Parse(input)
if err != nil {
b.Fatal(err)
}
}
})
}
Run benchmarks:
go test -bench=. -benchmem ./internal/parser/hl7v2/...
Test Coverage
Generate Coverage Report
# Run with coverage
go test -coverprofile=coverage.out ./...
# View coverage
go tool cover -html=coverage.out
# Coverage summary
go tool cover -func=coverage.out
Coverage Targets
| Package | Target |
|---|---|
| pkg/* | 80%+ |
| internal/parser/* | 85%+ |
| internal/workflow/* | 80%+ |
Test Data
Organizing Test Data
testdata/
├── hl7v2/
│ ├── adt/
│ │ ├── a01_basic.hl7
│ │ ├── a01_with_z_segments.hl7
│ │ └── a01_missing_nk1.hl7
│ ├── oru/
│ │ └── r01_multi_obx.hl7
│ └── siu/
│ └── s12_appointment.hl7
├── edi/
│ ├── 837p_sample.x12
│ └── 835_remit.x12
├── profiles/
│ ├── default.yaml
│ └── strict.yaml
├── workflows/
│ └── test_workflow.yaml
└── golden/
├── adt_a01_output.json
└── oru_r01_output.json
Creating Test Data
- Use real-world samples when possible (sanitized)
- Include edge cases (missing fields, extra data)
- Document what each sample tests
CI Integration
GitLab CI
test:unit:
stage: test
script:
- go test -v -race -coverprofile=coverage.out ./...
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
test:integration:
stage: test
services:
- postgres:14
- hapiproject/hapi:latest
script:
- go test -tags=integration ./test/e2e/...
Common Patterns
Testing Error Cases
func TestParser_Errors(t *testing.T) {
tests := []struct {
name string
input string
want string // Expected error substring
}{
{
name: "empty input",
input: "",
want: "empty message",
},
{
name: "invalid header",
input: "NOT_VALID_HL7",
want: "invalid MSH segment",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := NewParser(defaultProfile())
_, err := p.Parse([]byte(tt.input))
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), tt.want) {
t.Errorf("error = %q, want to contain %q", err, tt.want)
}
})
}
}
Testing with Profiles
func TestParser_WithDifferentProfiles(t *testing.T) {
input := loadTestData(t, "message_with_optional.hl7")
tests := []struct {
name string
profile *profile.Profile
wantErr bool
wantWarning bool
}{
{
name: "strict profile fails",
profile: strictProfile(),
wantErr: true,
},
{
name: "tolerant profile succeeds with warning",
profile: tolerantProfile(),
wantErr: false,
wantWarning: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := NewParser(tt.profile)
events, err := p.Parse(input)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantWarning && len(events) > 0 {
if len(events[0].Meta().ParseWarnings) == 0 {
t.Error("expected warnings")
}
}
})
}
}
See Also
- Development Setup - Environment setup
- Adding a Parser - Parser development
- Architecture Overview - System architecture