| name | test-coverage |
| description | Run tests and check coverage for Auth9 backend, frontend, and SDK with mock-based testing patterns. |
Test Coverage Skill
Run tests and check coverage for Auth9 project (backend, frontend, SDK).
When to Use
- Running unit tests
- Checking test coverage
- Writing new service layer tests with mocks
- Verifying test health before commits
Backend (auth9-core)
Run All Tests
cd auth9-core
cargo test
All tests run fast (~1-2 seconds) with no Docker or external services required.
Run Coverage Analysis
Use make coverage (wraps cargo-llvm-cov with exclusions):
cd auth9-core
make coverage
make coverage-html
make coverage-json
Why llvm-cov? tarpaulin has known issues with #[tonic::async_trait] macros (reports ~9% instead of actual ~87% for gRPC code). cargo-llvm-cov uses LLVM's instrumentation and correctly tracks async trait code.
Install if needed:
cargo install cargo-llvm-cov
Coverage Exclusions
The following files are excluded from coverage tracking:
| File/Directory | Reason |
|---|
repository/*.rs | Thin data mapping layer (ORM-equivalent), no business logic to test |
main.rs | Program entry point |
migration/*.rs | Database migration scripts |
These exclusions are configured in auth9-core/Makefile. Repository layer coverage is low by design - all business logic lives in the service layer, which is tested via mock repositories.
SDK (sdk/)
Run Tests
cd sdk
pnpm run --filter @auth9/core test
pnpm run --filter @auth9/node test
Or run all SDK tests at once:
cd sdk && pnpm test
Run Coverage
cd sdk
pnpm run --filter @auth9/core test:coverage
pnpm run --filter @auth9/node test:coverage
Coverage Exclusions
| File/Pattern | Reason |
|---|
src/index.ts | Barrel re-export, no logic |
src/types/**/*.ts (@auth9/core only) | Pure type definitions, no runtime code |
These exclusions are configured in each package's vitest.config.ts.
Test File Structure
sdk/packages/
├── core/src/
│ ├── utils.test.ts # toSnakeCase / toCamelCase (12 tests)
│ ├── errors.test.ts # Error hierarchy, createErrorFromStatus (17 tests)
│ ├── http-client.test.ts # HTTP client with fetch mocking (8 tests)
│ └── claims.test.ts # getTokenType() discrimination (3 tests)
└── node/src/
├── testing.test.ts # createMockToken, createMockAuth9 (5 tests)
└── client-credentials.test.ts # Token fetch, caching, cache clear (3 tests)
Writing SDK Tests
SDK tests use vitest with global fetch mocking (no external services):
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
beforeEach(() => {
mockFetch.mockReset();
});
it("fetches data with auto snake/camel conversion", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ data: { user_name: "test" } }),
});
const client = new Auth9HttpClient({ baseUrl: "http://localhost:8080" });
const result = await client.get<{ userName: string }>("/api/v1/users/1");
expect(result.userName).toBe("test");
});
Frontend (auth9-portal)
Run Tests
cd auth9-portal
npx vitest --run
Run Coverage
cd auth9-portal
npm run test:coverage
Testing Strategy
No External Dependencies
Auth9-core tests use mocks instead of real services:
| Component | Testing Approach |
|---|
| Repository layer | Mock traits with mockall |
| Service layer | Unit tests with mock repositories |
| gRPC services | NoOpCacheManager + mock repositories |
| External HTTP (OIDC) | wiremock HTTP mocking |
Prohibited
- No testcontainers - tests must not start Docker containers
- No real database connections - use mock repositories
- No real Redis connections - use
NoOpCacheManager
- No faker library - construct test data directly
Test File Structure
auth9-core/
├── src/
│ ├── service/
│ │ ├── tenant.rs # Service + unit tests (#[cfg(test)])
│ │ ├── user.rs # Service + unit tests
│ │ ├── client.rs # Service + unit tests
│ │ └── rbac.rs # Service + unit tests
│ └── repository/
│ └── *.rs # Traits with #[mockall::automock]
└── tests/
├── api_test.rs # Entry point for api/ and grpc/ tests
├── api/
│ ├── mod.rs # Test repositories (TestUserRepository, etc.)
│ └── http/ # HTTP handler tests
│ ├── mod.rs # TestAppState, build_test_router
│ └── *_http_test.rs # Handler tests
├── grpc/
│ ├── mod.rs # GrpcTestBuilder, MockCacheManager
│ ├── exchange_token_test.rs # exchange_token tests
│ ├── validate_token_test.rs # validate_token tests
│ ├── get_user_roles_test.rs # get_user_roles tests
│ └── introspect_token_test.rs # introspect_token tests
└── (wiremock-based HTTP tests inline in service modules)
Writing HTTP Handler Tests (API Layer)
HTTP handler tests use Dependency Injection via HasServices trait to test production code with mock repositories.
Architecture
Production: AppState (impl HasServices) → build_router() → Handlers<AppState>
Tests: TestAppState (impl HasServices) → build_router() → Handlers<TestAppState>
↑
Same production handlers!
Key Files
| File | Purpose |
|---|
src/state.rs | HasServices trait definition |
src/server/mod.rs | AppState + build_router<S: HasServices>() |
tests/api/http/mod.rs | TestAppState + test helpers |
tests/api/http/*_http_test.rs | HTTP handler tests |
HasServices Trait Pattern
All handlers use generic <S: HasServices> instead of concrete AppState:
pub async fn list<S: HasServices>(
State(state): State<S>,
Query(pagination): Query<PaginationQuery>,
) -> Result<impl IntoResponse> {
let (tenants, total) = state
.tenant_service()
.list(pagination.page, pagination.per_page)
.await?;
Ok(Json(PaginatedResponse::new(tenants, pagination.page, pagination.per_page, total)))
}
TestAppState Implementation
pub struct TestAppState {
pub tenant_service: Arc<TenantService<TestTenantRepository>>,
pub user_service: Arc<UserService<TestUserRepository>>,
}
impl HasServices for TestAppState {
type TenantRepo = TestTenantRepository;
type UserRepo = TestUserRepository;
fn tenant_service(&self) -> &TenantService<Self::TenantRepo> {
&self.tenant_service
}
}
Writing HTTP Tests
use crate::api::http::{build_test_router, get_json, post_json, TestAppState};
#[tokio::test]
async fn test_list_tenants_returns_200() {
let state = TestAppState::new("http://mock-oidc");
state.tenant_repo.seed_tenant(CreateTenantInput {
name: "Test".to_string(),
slug: "test".to_string(),
..Default::default()
}).await;
let app = build_test_router(state);
let (status, body): (_, Option<PaginatedResponse<Tenant>>) =
get_json(&app, "/api/v1/tenants").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.unwrap().data.len(), 1);
}
Important Rules
- Always use
HasServices trait for new handlers - never use concrete AppState
- Test production code -
build_test_router() uses build_router(state) from production
- Mock external HTTP with wiremock where needed for OIDC/HTTP tests
- Use test repositories -
TestTenantRepository, TestUserRepository, etc.
Writing Service Layer Tests
Service tests live in src/service/*.rs inside #[cfg(test)] modules:
#[cfg(test)]
mod tests {
use super::*;
use crate::repository::tenant::MockTenantRepository;
use mockall::predicate::*;
#[tokio::test]
async fn test_create_tenant_success() {
let mut mock = MockTenantRepository::new();
mock.expect_find_by_slug()
.with(eq("test-tenant"))
.returning(|_| Ok(None));
mock.expect_create()
.returning(|input| Ok(Tenant {
name: input.name.clone(),
slug: input.slug.clone(),
..Default::default()
}));
let service = TenantService::new(Arc::new(mock), None);
let input = CreateTenantInput {
name: "Test Tenant".to_string(),
slug: "test-tenant".to_string(),
logo_url: None,
settings: None,
};
let result = service.create(input).await;
assert!(result.is_ok());
}
}
Writing gRPC Tests
gRPC tests use NoOpCacheManager instead of real Redis:
use auth9_core::cache::NoOpCacheManager;
fn create_test_cache() -> NoOpCacheManager {
NoOpCacheManager::new()
}
#[tokio::test]
async fn test_exchange_token() {
let cache_manager = create_test_cache();
let grpc_service = TokenExchangeService::new(
jwt_manager,
cache_manager,
user_repo,
service_repo,
rbac_repo,
);
}
Writing External HTTP Tests
Use wiremock to mock external HTTP endpoints (e.g. OIDC discovery):
use wiremock::{Mock, ResponseTemplate, matchers::{method, path}};
#[tokio::test]
async fn test_oidc_operation() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/openid-configuration"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"issuer": mock_server.uri(),
"token_endpoint": format!("{}/token", mock_server.uri())
})))
.mount(&mock_server)
.await;
}
Coverage Targets
All test coverage targets in this skill follow the project minimum requirement: >=90%.
| Layer | Target | Notes |
|---|
| Domain/Business logic | >=90% | Pure validation, no I/O |
| Service layer | >=90% | Core business logic |
| API handlers | >=90% | HTTP routing |
| gRPC handlers | >=90% | Token exchange, validation |
| Repository layer | N/A | Excluded from tracking (thin data mapping) |
| SDK @auth9/core | >=90% | Utils, errors, HTTP client |
| SDK @auth9/node | >=90% | Token verifier, gRPC client, middleware, testing |
Troubleshooting
- Compilation errors: Run
cargo clean first
- Mock expectations not met: Check predicate conditions
- Low gRPC coverage with tarpaulin: Use
cargo llvm-cov instead (tarpaulin can't track #[async_trait] macros properly)
- llvm-cov not found: Install with
cargo install cargo-llvm-cov