Testing guide
Learn how to test routed components, navigation flows, and router functionality in Aurelia applications.
Testing routing functionality is crucial for maintaining robust applications. This guide covers testing patterns for routed components, navigation flows, router hooks, and integration scenarios using Aurelia's testing utilities.
Testing Setup
Basic Test Configuration
import { Aurelia, StandardConfiguration } from '@aurelia/runtime-html';
import { RouterConfiguration, IRouter } from '@aurelia/router';
import { TestContext, TestConfiguration } from '@aurelia/testing';
// Test helper for router setup
export function createRouterTestContext() {
return TestContext.create(
StandardConfiguration,
RouterConfiguration,
TestConfiguration
);
}
// Router test base class
export class RouterTestBase {
protected ctx: TestContext;
protected router: IRouter;
protected au: Aurelia;
async beforeEach() {
this.ctx = createRouterTestContext();
this.au = this.ctx.container.get(Aurelia);
this.router = this.ctx.container.get(IRouter);
// Start without initial navigation
await this.au.start();
this.router.start(false);
}
async afterEach() {
await this.au.stop();
this.ctx.dispose();
}
}
Testing Routed Components
Testing Component Loading and Lifecycle
import { route, IRouteViewModel, Params } from '@aurelia/router';
import { RouterTestBase } from './router-test-base';
// Component under test
@route('product/:id')
export class ProductDetailComponent implements IRouteViewModel {
public product: Product | null = null;
public loading = false;
public error: string | null = null;
constructor(private productService: ProductService) {}
async canLoad(params: Params): Promise<boolean> {
const id = parseInt(params.id);
return !isNaN(id) && id > 0;
}
async loading(params: Params): Promise<void> {
this.loading = true;
try {
this.product = await this.productService.getProduct(params.id);
} catch (error) {
this.error = 'Failed to load product';
} finally {
this.loading = false;
}
}
}
// Tests
describe('ProductDetailComponent', () => {
let testBase: RouterTestBase;
let mockProductService: MockProductService;
let component: ProductDetailComponent;
beforeEach(async () => {
testBase = new RouterTestBase();
mockProductService = new MockProductService();
// Register mock service
testBase.ctx.container.register(
Registration.instance(ProductService, mockProductService)
);
await testBase.beforeEach();
});
afterEach(async () => {
await testBase.afterEach();
});
describe('canLoad', () => {
it('should allow loading with valid product ID', async () => {
component = testBase.ctx.container.get(ProductDetailComponent);
const result = await component.canLoad({ id: '123' });
expect(result).toBe(true);
});
it('should reject loading with invalid product ID', async () => {
component = testBase.ctx.container.get(ProductDetailComponent);
const result = await component.canLoad({ id: 'invalid' });
expect(result).toBe(false);
});
});
describe('loading', () => {
beforeEach(() => {
component = testBase.ctx.container.get(ProductDetailComponent);
});
it('should load product data successfully', async () => {
const mockProduct = { id: 123, name: 'Test Product' };
mockProductService.getProduct.mockResolvedValue(mockProduct);
await component.loading({ id: '123' });
expect(component.product).toEqual(mockProduct);
expect(component.loading).toBe(false);
expect(component.error).toBeNull();
});
it('should handle loading errors', async () => {
mockProductService.getProduct.mockRejectedValue(new Error('API Error'));
await component.loading({ id: '123' });
expect(component.product).toBeNull();
expect(component.error).toBe('Failed to load product');
expect(component.loading).toBe(false);
});
});
});
Testing Component with Router Dependencies
import { ICurrentRoute, IRouter } from '@aurelia/router';
export class NavigationComponent {
constructor(
private router: IRouter,
private currentRoute: ICurrentRoute
) {}
async navigateToProduct(productId: string) {
return this.router.load(`product/${productId}`);
}
get currentProductId(): string | null {
const params = this.currentRoute.parameterInformation[0]?.params;
return params?.id ?? null;
}
get isProductPage(): boolean {
return this.currentRoute.path.startsWith('/product');
}
}
// Test with mocked router
describe('NavigationComponent', () => {
let component: NavigationComponent;
let mockRouter: jest.Mocked<IRouter>;
let mockCurrentRoute: jest.Mocked<ICurrentRoute>;
beforeEach(() => {
mockRouter = {
load: jest.fn(),
isActive: jest.fn(),
// ... other router methods
} as any;
mockCurrentRoute = {
path: '/product/123',
parameterInformation: [
{ params: { id: '123' }, config: null, viewport: null, children: [] }
],
// ... other current route properties
} as any;
component = new NavigationComponent(mockRouter, mockCurrentRoute);
});
it('should navigate to product', async () => {
mockRouter.load.mockResolvedValue(true);
const result = await component.navigateToProduct('456');
expect(mockRouter.load).toHaveBeenCalledWith('product/456');
expect(result).toBe(true);
});
it('should get current product ID', () => {
const productId = component.currentProductId;
expect(productId).toBe('123');
});
it('should detect product page', () => {
expect(component.isProductPage).toBe(true);
});
});
Testing Navigation Flows
Testing Programmatic Navigation
describe('Navigation Flows', () => {
let testBase: RouterTestBase;
beforeEach(async () => {
testBase = new RouterTestBase();
await testBase.beforeEach();
});
afterEach(async () => {
await testBase.afterEach();
});
it('should navigate to route successfully', async () => {
const success = await testBase.router.load('home');
expect(success).toBe(true);
// Verify current route
const currentRoute = testBase.ctx.container.get(ICurrentRoute);
expect(currentRoute.path).toBe('/home');
});
it('should handle navigation with parameters', async () => {
const success = await testBase.router.load('product/123');
expect(success).toBe(true);
const currentRoute = testBase.ctx.container.get(ICurrentRoute);
const params = currentRoute.parameterInformation[0]?.params;
expect(params?.id).toBe('123');
});
it('should handle navigation with query parameters', async () => {
const success = await testBase.router.load('search', {
queryParams: { q: 'test', page: '2' }
});
expect(success).toBe(true);
const currentRoute = testBase.ctx.container.get(ICurrentRoute);
expect(currentRoute.query.get('q')).toBe('test');
expect(currentRoute.query.get('page')).toBe('2');
});
it('should handle navigation failure', async () => {
try {
await testBase.router.load('non-existent-route');
fail('Expected navigation to fail');
} catch (error) {
expect(error).toBeDefined();
}
});
});
Testing Navigation Events
import { IRouterEvents, NavigationStartEvent, NavigationEndEvent } from '@aurelia/router';
describe('Navigation Events', () => {
let testBase: RouterTestBase;
let routerEvents: IRouterEvents;
let navigationStartSpy: jest.Mock;
let navigationEndSpy: jest.Mock;
beforeEach(async () => {
testBase = new RouterTestBase();
await testBase.beforeEach();
routerEvents = testBase.ctx.container.get(IRouterEvents);
navigationStartSpy = jest.fn();
navigationEndSpy = jest.fn();
routerEvents.subscribe('au:router:navigation-start', navigationStartSpy);
routerEvents.subscribe('au:router:navigation-end', navigationEndSpy);
});
afterEach(async () => {
await testBase.afterEach();
});
it('should emit navigation events', async () => {
await testBase.router.load('home');
expect(navigationStartSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'au:router:navigation-start',
instructions: expect.any(Object)
})
);
expect(navigationEndSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'au:router:navigation-end',
instructions: expect.any(Object),
finalInstructions: expect.any(Object)
})
);
});
});
Testing Router Hooks
Testing Lifecycle Hooks
import { lifecycleHooks, IRouteViewModel, Params, RouteNode } from '@aurelia/router';
@lifecycleHooks()
export class AuthenticationHook {
constructor(private authService: AuthService) {}
async canLoad(
params: Params,
next: RouteNode,
current: RouteNode | null,
viewModel: IRouteViewModel
): Promise<boolean> {
const requiresAuth = next.data?.requiresAuth ?? false;
if (!requiresAuth) return true;
const isAuthenticated = await this.authService.isAuthenticated();
if (!isAuthenticated) {
// Redirect to login
next.router.load('login', {
queryParams: { returnUrl: next.path }
});
return false;
}
return true;
}
}
// Test the hook
describe('AuthenticationHook', () => {
let hook: AuthenticationHook;
let mockAuthService: jest.Mocked<AuthService>;
let mockRouteNode: jest.Mocked<RouteNode>;
let mockRouter: jest.Mocked<IRouter>;
beforeEach(() => {
mockAuthService = {
isAuthenticated: jest.fn()
} as any;
mockRouter = {
load: jest.fn()
} as any;
mockRouteNode = {
data: {},
path: '/protected',
router: mockRouter
} as any;
hook = new AuthenticationHook(mockAuthService);
});
it('should allow access to public routes', async () => {
mockRouteNode.data = { requiresAuth: false };
const result = await hook.canLoad({}, mockRouteNode, null, {} as any);
expect(result).toBe(true);
expect(mockAuthService.isAuthenticated).not.toHaveBeenCalled();
});
it('should allow access for authenticated users', async () => {
mockRouteNode.data = { requiresAuth: true };
mockAuthService.isAuthenticated.mockResolvedValue(true);
const result = await hook.canLoad({}, mockRouteNode, null, {} as any);
expect(result).toBe(true);
expect(mockAuthService.isAuthenticated).toHaveBeenCalled();
});
it('should redirect unauthenticated users to login', async () => {
mockRouteNode.data = { requiresAuth: true };
mockAuthService.isAuthenticated.mockResolvedValue(false);
const result = await hook.canLoad({}, mockRouteNode, null, {} as any);
expect(result).toBe(false);
expect(mockRouter.load).toHaveBeenCalledWith('login', {
queryParams: { returnUrl: '/protected' }
});
});
});
Testing Custom Attributes
Testing load
Custom Attribute
load
Custom Attributeimport { LoadCustomAttribute } from '@aurelia/router';
describe('LoadCustomAttribute', () => {
let testBase: RouterTestBase;
let loadAttribute: LoadCustomAttribute;
let mockElement: HTMLAnchorElement;
beforeEach(async () => {
testBase = new RouterTestBase();
await testBase.beforeEach();
mockElement = document.createElement('a');
// Create load attribute with mocked element
loadAttribute = testBase.ctx.container.get(LoadCustomAttribute);
(loadAttribute as any)._el = mockElement;
});
afterEach(async () => {
await testBase.afterEach();
});
it('should generate correct href for route', async () => {
loadAttribute.route = 'product';
loadAttribute.params = { id: '123' };
loadAttribute.valueChanged();
expect(mockElement.getAttribute('href')).toBe('/product/123');
});
it('should handle click events', async () => {
const routerLoadSpy = jest.spyOn(testBase.router, 'load');
loadAttribute.route = 'home';
loadAttribute.valueChanged();
// Simulate click
const clickEvent = new MouseEvent('click', { bubbles: true });
mockElement.dispatchEvent(clickEvent);
expect(routerLoadSpy).toHaveBeenCalledWith('home');
});
});
Integration Testing
Testing Full Application Flows
import { CustomElement } from '@aurelia/runtime-html';
// App component with routing
@route({
routes: [
{ path: '', redirectTo: 'home' },
{ path: 'home', component: HomeComponent },
{ path: 'product/:id', component: ProductDetailComponent },
{ path: 'login', component: LoginComponent }
]
})
@customElement({
name: 'app',
template: '<au-viewport></au-viewport>'
})
export class App {}
describe('App Integration', () => {
let ctx: TestContext;
let au: Aurelia;
let router: IRouter;
let host: HTMLElement;
beforeEach(async () => {
ctx = createRouterTestContext();
au = ctx.container.get(Aurelia);
router = ctx.container.get(IRouter);
host = ctx.createElement('<app></app>');
au.app({ host, component: App });
await au.start();
router.start(false);
});
afterEach(async () => {
await au.stop();
ctx.dispose();
});
it('should redirect to home on initial load', async () => {
await router.load('');
// Wait for navigation to complete
await ctx.platform.domQueue.flush();
expect(host.textContent).toContain('Home Component');
});
it('should navigate to product detail', async () => {
await router.load('product/123');
await ctx.platform.domQueue.flush();
expect(host.textContent).toContain('Product 123');
});
it('should handle navigation with browser back/forward', async () => {
// Navigate forward
await router.load('product/123');
await ctx.platform.domQueue.flush();
await router.load('home');
await ctx.platform.domQueue.flush();
// Simulate browser back
history.back();
await ctx.platform.domQueue.flush();
expect(host.textContent).toContain('Product 123');
});
});
Test Utilities and Helpers
Router Test Utilities
export class RouterTestUtils {
static async waitForNavigation(router: IRouter): Promise<void> {
return new Promise<void>((resolve) => {
if (!router.isNavigating) {
resolve();
return;
}
const events = router.container.get(IRouterEvents);
const subscription = events.subscribe('au:router:navigation-end', () => {
subscription.dispose();
resolve();
});
});
}
static createMockRouteNode(overrides: Partial<RouteNode> = {}): RouteNode {
return {
path: '',
finalPath: '',
title: null,
data: {},
params: {},
queryParams: new URLSearchParams(),
fragment: '',
children: [],
...overrides
} as RouteNode;
}
static createMockCurrentRoute(overrides: Partial<ICurrentRoute> = {}): ICurrentRoute {
return {
path: '',
url: '',
title: '',
query: new URLSearchParams(),
parameterInformation: [],
...overrides
} as ICurrentRoute;
}
}
Mock Services
export class MockRouter implements Partial<IRouter> {
public isNavigating = false;
public routeTree: any = {};
public currentTr: any = {};
load = jest.fn().mockResolvedValue(true);
isActive = jest.fn().mockReturnValue(false);
generatePath = jest.fn().mockResolvedValue('');
start = jest.fn();
stop = jest.fn();
}
export class MockRouterEvents implements Partial<IRouterEvents> {
private subscriptions = new Map<string, Function[]>();
subscribe = jest.fn((event: string, callback: Function) => {
if (!this.subscriptions.has(event)) {
this.subscriptions.set(event, []);
}
this.subscriptions.get(event)!.push(callback);
return {
dispose: () => {
const callbacks = this.subscriptions.get(event);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) callbacks.splice(index, 1);
}
}
};
});
publish(event: any) {
const callbacks = this.subscriptions.get(event.name) || [];
callbacks.forEach(callback => callback(event));
}
}
Best Practices
1. Test Isolation
// ✅ Good - Each test gets fresh router instance
beforeEach(async () => {
testContext = createRouterTestContext();
await testContext.beforeEach();
});
// ❌ Avoid - Shared router state between tests
const sharedRouter = new Router(); // Don't do this
2. Mock External Dependencies
// ✅ Good - Mock services that router components depend on
beforeEach(() => {
container.register(
Registration.instance(ApiService, mockApiService)
);
});
// ❌ Avoid - Using real services in unit tests
// This makes tests slow and brittle
3. Test Route Configuration
// ✅ Good - Test route configuration separately
it('should configure routes correctly', () => {
const config = MyApp.routes;
expect(config).toContainEqual(
expect.objectContaining({
path: 'product/:id',
component: ProductComponent
})
);
});
This comprehensive testing guide provides developers with the tools and patterns needed to thoroughly test routing functionality, addressing a critical gap in the router documentation.
Last updated
Was this helpful?