Outcome Recipes

Advanced testing patterns for complex scenarios including async operations, routing, service mocking, and end-to-end component interaction testing.

These recipes show how to test complex real-world scenarios in Aurelia applications. Use them when basic component testing isn't enough.

1. Testing components with async API calls

Goal: Test components that load data from APIs with proper handling of loading states, successful responses, and error scenarios.

Steps

  1. Create a mock HTTP client with configurable responses:

    import { IHttpClient } from '@aurelia/fetch-client';
    import { Registration } from '@aurelia/kernel';
    
    export class MockHttpClient implements IHttpClient {
      private mockResponses = new Map<string, any>();
      private mockErrors = new Map<string, Error>();
    
      baseUrl = '';
      activeRequestCount = 0;
      isRequesting = false;
    
      configure() {
        return this;
      }
    
      setMockResponse(url: string, data: any, status: number = 200) {
        this.mockResponses.set(url, { data, status });
      }
    
      setMockError(url: string, error: Error) {
        this.mockErrors.set(url, error);
      }
    
      async fetch(input: RequestInfo | Request): Promise<Response> {
        const url = typeof input === 'string' ? input : input.url;
    
        if (this.mockErrors.has(url)) {
          throw this.mockErrors.get(url);
        }
    
        const mock = this.mockResponses.get(url);
        if (!mock) {
          throw new Error(`No mock response configured for ${url}`);
        }
    
        return new Response(JSON.stringify(mock.data), {
          status: mock.status,
          headers: { 'Content-Type': 'application/json' }
        });
      }
    }
    
    export const MockHttpClientRegistration = Registration.instance(
      IHttpClient,
      new MockHttpClient()
    );
  2. Test component with successful data loading:

    import { createFixture } from '@aurelia/testing';
    import { MockHttpClient, MockHttpClientRegistration } from './mock-http-client';
    import { ProductList } from './product-list';
    
    describe('ProductList', () => {
      it('should load and display products', async () => {
        const mockHttp = new MockHttpClient();
        mockHttp.setMockResponse('/api/products', {
          products: [
            { id: '1', name: 'Product 1', price: 10 },
            { id: '2', name: 'Product 2', price: 20 }
          ]
        });
    
        const { component, assertText, platform } = await createFixture
          .component(ProductList)
          .html`<div>
            <div if.bind="loading">Loading...</div>
            <div if.bind="error">\${error}</div>
            <div repeat.for="product of products">\${product.name}</div>
          </div>`
          .deps(Registration.instance(IHttpClient, mockHttp))
          .build()
          .started;
    
        // Initial loading state
        assertText('Loading...');
    
        // Wait for async attached() to complete
        await platform.taskQueue.yield();
    
        // Verify products are displayed
        assertText('Product 1Product 2');
        expect(component.loading).toBe(false);
        expect(component.products.length).toBe(2);
    
        await fixture.stop(true);
      });
    });
  3. Test error handling:

    it('should display error message when API fails', async () => {
      const mockHttp = new MockHttpClient();
      mockHttp.setMockError('/api/products', new Error('Network error'));
    
      const { component, assertText, platform } = await createFixture
        .component(ProductList)
        .html`<div>
          <div if.bind="loading">Loading...</div>
          <div if.bind="error">\${error}</div>
          <div repeat.for="product of products">\${product.name}</div>
        </div>`
        .deps(Registration.instance(IHttpClient, mockHttp))
        .build()
        .started;
    
      // Wait for async operation
      await platform.taskQueue.yield();
    
      // Verify error is displayed
      expect(component.error).toBeTruthy();
      expect(component.products.length).toBe(0);
      expect(component.loading).toBe(false);
    
      await fixture.stop(true);
    });
  4. Test retry functionality:

    it('should retry loading when retry button is clicked', async () => {
      const mockHttp = new MockHttpClient();
      let callCount = 0;
    
      // First call fails, second succeeds
      mockHttp.fetch = async (input: RequestInfo | Request) => {
        callCount++;
        if (callCount === 1) {
          throw new Error('Temporary error');
        }
        return new Response(JSON.stringify({
          products: [{ id: '1', name: 'Product 1', price: 10 }]
        }), {
          status: 200,
          headers: { 'Content-Type': 'application/json' }
        });
      };
    
      const { component, trigger, assertText, platform } = await createFixture
        .component(ProductList)
        .html`<div>
          <div if.bind="loading">Loading...</div>
          <div if.bind="error">
            \${error}
            <button click.trigger="retry()">Retry</button>
          </div>
          <div repeat.for="product of products">\${product.name}</div>
        </div>`
        .deps(Registration.instance(IHttpClient, mockHttp))
        .build()
        .started;
    
      // Wait for initial failed load
      await platform.taskQueue.yield();
      expect(component.error).toBeTruthy();
    
      // Click retry button
      trigger.click('button');
    
      // Wait for retry to complete
      await platform.taskQueue.yield();
    
      // Verify success
      assertText('Product 1');
      expect(component.error).toBeNull();
      expect(callCount).toBe(2);
    
      await fixture.stop(true);
    });

Checklist

  • Loading state displays before data arrives

  • Successful API calls populate component data

  • Error states are handled and displayed

  • Retry functionality reloads data

  • All async operations use platform.taskQueue.yield() for timing

2. Testing router navigation and route parameters

Goal: Test components that use routing for navigation, parameter extraction, and route guards.

Steps

  1. Set up a test with router configuration:

    import { createFixture } from '@aurelia/testing';
    import { IRouter, RouterConfiguration } from '@aurelia/router';
    import { ProductDetail } from './product-detail';
    import { ProductList } from './product-list';
    
    describe('Router navigation', () => {
      it('should navigate to product detail with correct parameter', async () => {
        const { component, container, platform } = await createFixture
          .component(class App {
            static routes = [
              { path: '', redirect: 'products' },
              { path: 'products', component: ProductList, title: 'Products' },
              { path: 'products/:id', component: ProductDetail, title: 'Product' }
            ];
          })
          .html`<au-viewport></au-viewport>`
          .deps(RouterConfiguration, ProductList, ProductDetail)
          .build()
          .started;
    
        const router = container.get(IRouter);
    
        // Navigate to product detail
        await router.load('products/123');
        await platform.taskQueue.yield();
    
        // Verify navigation occurred
        expect(router.currentRoute?.path).toContain('products/123');
    
        await fixture.stop(true);
      });
    });
  2. Test route parameter extraction:

    it('should extract and use route parameters', async () => {
      let loadedProductId: string | null = null;
    
      class TestProductDetail {
        productId: string = '';
    
        loading(params: any) {
          this.productId = params.id;
          loadedProductId = params.id;
        }
      }
    
      const { container, platform } = await createFixture
        .component(class App {
          static routes = [
            { path: 'products/:id', component: TestProductDetail }
          ];
        })
        .html`<au-viewport></au-viewport>`
        .deps(RouterConfiguration, TestProductDetail)
        .build()
        .started;
    
      const router = container.get(IRouter);
    
      // Navigate with parameter
      await router.load('products/456');
      await platform.taskQueue.yield();
    
      // Verify parameter was extracted
      expect(loadedProductId).toBe('456');
    
      await fixture.stop(true);
    });
  3. Test route guards:

    it('should prevent navigation when canLoad returns false', async () => {
      let canLoadCalled = false;
      let componentLoaded = false;
    
      class ProtectedComponent {
        canLoad() {
          canLoadCalled = true;
          return false; // Deny navigation
        }
    
        loading() {
          componentLoaded = true;
        }
      }
    
      const { container, platform } = await createFixture
        .component(class App {
          static routes = [
            { path: '', component: class Home {} },
            { path: 'protected', component: ProtectedComponent }
          ];
        })
        .html`<au-viewport></au-viewport>`
        .deps(RouterConfiguration, ProtectedComponent)
        .build()
        .started;
    
      const router = container.get(IRouter);
    
      // Attempt to navigate to protected route
      await router.load('protected');
      await platform.taskQueue.yield();
    
      // Verify navigation was blocked
      expect(canLoadCalled).toBe(true);
      expect(componentLoaded).toBe(false);
      expect(router.currentRoute?.path).not.toContain('protected');
    
      await fixture.stop(true);
    });
  4. Test route guard redirects:

    it('should redirect when canLoad returns a different path', async () => {
      class AuthGuard {
        canLoad() {
          // Simulate unauthenticated user
          return '/login'; // Redirect
        }
      }
    
      class LoginPage {
        loaded = false;
        loading() {
          this.loaded = true;
        }
      }
    
      const { container, platform } = await createFixture
        .component(class App {
          static routes = [
            { path: 'dashboard', component: AuthGuard },
            { path: 'login', component: LoginPage }
          ];
        })
        .html`<au-viewport></au-viewport>`
        .deps(RouterConfiguration, AuthGuard, LoginPage)
        .build()
        .started;
    
      const router = container.get(IRouter);
    
      // Try to access protected route
      await router.load('dashboard');
      await platform.taskQueue.yield();
    
      // Verify redirect to login
      expect(router.currentRoute?.path).toContain('login');
    
      await fixture.stop(true);
    });

Checklist

  • Router navigation works in tests

  • Route parameters are extracted correctly

  • canLoad guards are invoked and respected

  • Redirects from guards work as expected

  • Current route state is verifiable

3. Testing with validation

Goal: Test form validation including rules, error display, and submission prevention.

Steps

  1. Set up validation testing environment:

    import { createFixture } from '@aurelia/testing';
    import { ValidationHtmlConfiguration } from '@aurelia/validation-html';
    import { IValidationRules } from '@aurelia/validation';
    import { IValidationController } from '@aurelia/validation-html';
    import { newInstanceForScope, resolve } from '@aurelia/kernel';
    
    describe('Form validation', () => {
      it('should validate required fields and show errors', async () => {
        class SignupForm {
          user = { email: '', password: '' };
          controller = resolve(newInstanceForScope(IValidationController));
    
          constructor(private rules = resolve(IValidationRules)) {
            this.rules
              .on(this.user)
              .ensure('email')
                .required().withMessage('Email is required')
                .email().withMessage('Email must be valid')
              .ensure('password')
                .required().withMessage('Password is required')
                .minLength(8).withMessage('Password must be at least 8 characters');
          }
    
          async submit() {
            const result = await this.controller.validate();
            return result.valid;
          }
        }
    
        const { component, platform } = await createFixture
          .component(SignupForm)
          .html`<div>
            <div validation-errors.from-view="emailErrors">
              <input value.bind="user.email & validate:manual">
              <span repeat.for="error of emailErrors">\${error.result.message}</span>
            </div>
            <div validation-errors.from-view="passwordErrors">
              <input value.bind="user.password & validate:manual">
              <span repeat.for="error of passwordErrors">\${error.result.message}</span>
            </div>
          </div>`
          .deps(ValidationHtmlConfiguration)
          .build()
          .started;
    
        // Submit without filling form
        const isValid = await component.submit();
        await platform.taskQueue.yield();
    
        // Verify validation failed
        expect(isValid).toBe(false);
        expect(component.controller.results.length).toBe(2); // Two errors
    
        await fixture.stop(true);
      });
    });
  2. Test validation with valid input:

    it('should pass validation with valid input', async () => {
      class SignupForm {
        user = { email: '[email protected]', password: 'SecurePass123' };
        controller = resolve(newInstanceForScope(IValidationController));
    
        constructor(private rules = resolve(IValidationRules)) {
          this.rules
            .on(this.user)
            .ensure('email').required().email()
            .ensure('password').required().minLength(8);
        }
    
        async submit() {
          const result = await this.controller.validate();
          return result.valid;
        }
      }
    
      const { component, platform } = await createFixture
        .component(SignupForm)
        .html`<div>
          <input value.bind="user.email & validate:manual">
          <input value.bind="user.password & validate:manual">
        </div>`
        .deps(ValidationHtmlConfiguration)
        .build()
        .started;
    
      // Submit with valid data
      const isValid = await component.submit();
      await platform.taskQueue.yield();
    
      // Verify validation passed
      expect(isValid).toBe(true);
      expect(component.controller.results.length).toBe(0);
    
      await fixture.stop(true);
    });
  3. Test field-level validation on blur:

    it('should validate individual fields on blur', async () => {
      class FormComponent {
        user = { email: '' };
        controller = resolve(newInstanceForScope(IValidationController));
    
        constructor(private rules = resolve(IValidationRules)) {
          this.rules
            .on(this.user)
            .ensure('email').required().email();
        }
      }
    
      const { component, type, trigger, getBy, platform } = await createFixture
        .component(FormComponent)
        .html`<div validation-errors.from-view="emailErrors">
          <input id="email" value.bind="user.email & validate:blur">
          <span repeat.for="error of emailErrors">\${error.result.message}</span>
        </div>`
        .deps(ValidationHtmlConfiguration)
        .build()
        .started;
    
      // Type invalid email
      type('#email', 'invalid-email');
    
      // Trigger blur event
      trigger('#email', new Event('blur'));
      await platform.taskQueue.yield();
    
      // Verify validation error appears
      const results = component.controller.results;
      expect(results.length).toBeGreaterThan(0);
      expect(results[0].valid).toBe(false);
    
      await fixture.stop(true);
    });

Checklist

  • Validation rules are checked on submit

  • Invalid data prevents form submission

  • Valid data passes validation

  • Field-level validation triggers on blur

  • Error messages are accessible via controller

4. Testing complex component interactions

Goal: Test parent-child component communication, custom events, and state synchronization.

Steps

  1. Test parent-child data binding:

    import { createFixture } from '@aurelia/testing';
    import { bindable } from '@aurelia/runtime';
    
    describe('Component interactions', () => {
      it('should pass data from parent to child via bindable', async () => {
        class ChildComponent {
          @bindable value: string = '';
        }
    
        class ParentComponent {
          message = 'Hello from parent';
        }
    
        const { component, getBy, platform } = await createFixture
          .component(ParentComponent)
          .html`<div>
            <child-component value.bind="message"></child-component>
          </div>`
          .deps(ChildComponent)
          .build()
          .started;
    
        await platform.taskQueue.yield();
    
        // Get child component instance
        const childElement = getBy('child-component');
        const childComponent = childElement.au?.controller?.viewModel;
    
        // Verify data was passed to child
        expect(childComponent.value).toBe('Hello from parent');
    
        // Update parent data
        component.message = 'Updated message';
        await platform.taskQueue.yield();
    
        // Verify child received update
        expect(childComponent.value).toBe('Updated message');
    
        await fixture.stop(true);
      });
    });
  2. Test child-to-parent communication via custom events:

    it('should emit custom events from child to parent', async () => {
      class ChildComponent {
        sendMessage() {
          this.dispatchCustomEvent('message-sent', { text: 'Hello parent!' });
        }
    
        private dispatchCustomEvent(name: string, detail: any) {
          const event = new CustomEvent(name, { detail, bubbles: true });
          this.element.dispatchEvent(event);
        }
      }
    
      class ParentComponent {
        receivedMessage = '';
    
        handleMessage(event: CustomEvent) {
          this.receivedMessage = event.detail.text;
        }
      }
    
      const { component, trigger, platform } = await createFixture
        .component(ParentComponent)
        .html`<div>
          <child-component message-sent.trigger="handleMessage($event)"></child-component>
        </div>`
        .deps(ChildComponent)
        .build()
        .started;
    
      // Trigger child method that dispatches event
      const childElement = fixture.getBy('child-component');
      const childComponent = childElement.au?.controller?.viewModel;
    
      childComponent.sendMessage();
      await platform.taskQueue.yield();
    
      // Verify parent received message
      expect(component.receivedMessage).toBe('Hello parent!');
    
      await fixture.stop(true);
    });
  3. Test sibling component communication via shared service:

    it('should synchronize state between siblings via service', async () => {
      class SharedState {
        @observable count = 0;
    
        increment() {
          this.count++;
        }
      }
    
      class ComponentA {
        private state = resolve(SharedState);
    
        increment() {
          this.state.increment();
        }
    
        get count() {
          return this.state.count;
        }
      }
    
      class ComponentB {
        private state = resolve(SharedState);
    
        get count() {
          return this.state.count;
        }
      }
    
      const { getBy, trigger, platform } = await createFixture
        .component(class App {})
        .html`<div>
          <component-a></component-a>
          <component-b></component-b>
        </div>`
        .deps(SharedState, ComponentA, ComponentB)
        .build()
        .started;
    
      const compA = getBy('component-a').au?.controller?.viewModel;
      const compB = getBy('component-b').au?.controller?.viewModel;
    
      // Initial state
      expect(compA.count).toBe(0);
      expect(compB.count).toBe(0);
    
      // Increment from component A
      compA.increment();
      await platform.taskQueue.yield();
    
      // Verify both components reflect the change
      expect(compA.count).toBe(1);
      expect(compB.count).toBe(1);
    
      await fixture.stop(true);
    });

Checklist

  • Parent-to-child data flows via @bindable

  • Child-to-parent communication works via events

  • Sibling components share state via services

  • Changes propagate correctly across component tree

  • Custom events are dispatched and handled

5. Testing lifecycle hooks in complex scenarios

Goal: Test components with complex initialization, async lifecycle hooks, and cleanup operations.

Steps

  1. Test async data loading in lifecycle hooks:

    import { createFixture } from '@aurelia/testing';
    
    describe('Lifecycle hooks', () => {
      it('should complete async attached hook before rendering', async () => {
        let attachedCompleted = false;
        const loadedData = { id: 1, name: 'Test' };
    
        class AsyncComponent {
          data: any = null;
    
          async attached() {
            // Simulate API call
            await new Promise(resolve => setTimeout(resolve, 10));
            this.data = loadedData;
            attachedCompleted = true;
          }
        }
    
        const { component, assertText } = await createFixture
          .component(AsyncComponent)
          .html`<div>\${data?.name || 'Loading...'}</div>`
          .build()
          .started; // .started waits for all async lifecycle hooks
    
        // By the time .started resolves, attached() should be complete
        expect(attachedCompleted).toBe(true);
        expect(component.data).toEqual(loadedData);
        assertText('Test');
    
        await fixture.stop(true);
      });
    });
  2. Test cleanup in detaching/unbinding:

    it('should cleanup resources in detaching hook', async () => {
      let cleanupCalled = false;
      let subscriptionActive = true;
    
      class ComponentWithCleanup {
        private intervalId: number | null = null;
    
        attached() {
          // Simulate subscription or interval
          this.intervalId = window.setInterval(() => {
            // Do something
          }, 1000);
          subscriptionActive = true;
        }
    
        detaching() {
          // Cleanup
          if (this.intervalId !== null) {
            window.clearInterval(this.intervalId);
            subscriptionActive = false;
            cleanupCalled = true;
          }
        }
      }
    
      const fixture = await createFixture
        .component(ComponentWithCleanup)
        .html`<div>Content</div>`
        .build()
        .started;
    
      expect(subscriptionActive).toBe(true);
    
      // Stop the fixture (triggers detaching)
      await fixture.stop(true);
    
      // Verify cleanup was called
      expect(cleanupCalled).toBe(true);
      expect(subscriptionActive).toBe(false);
    });
  3. Test lifecycle hook order:

    it('should execute lifecycle hooks in correct order', async () => {
      const hookCalls: string[] = [];
    
      class LifecycleComponent {
        binding() {
          hookCalls.push('binding');
        }
    
        bound() {
          hookCalls.push('bound');
        }
    
        attaching() {
          hookCalls.push('attaching');
        }
    
        attached() {
          hookCalls.push('attached');
        }
    
        detaching() {
          hookCalls.push('detaching');
        }
    
        unbinding() {
          hookCalls.push('unbinding');
        }
      }
    
      const fixture = await createFixture
        .component(LifecycleComponent)
        .html`<div>Test</div>`
        .build()
        .started;
    
      // Verify initialization order
      expect(hookCalls).toEqual([
        'binding',
        'bound',
        'attaching',
        'attached'
      ]);
    
      hookCalls.length = 0; // Clear array
    
      await fixture.stop(true);
    
      // Verify cleanup order
      expect(hookCalls).toEqual([
        'detaching',
        'unbinding'
      ]);
    });

Checklist

  • Async lifecycle hooks complete before component is ready

  • Cleanup hooks properly dispose of resources

  • Hook execution order is correct

  • .started waits for all async hooks to complete

  • stop(true) triggers cleanup hooks

6. Testing with real-world dependencies

Goal: Test components that depend on multiple services, handle complex state, and integrate with external systems.

Steps

  1. Create comprehensive mocks for service dependencies:

    import { Registration } from '@aurelia/kernel';
    
    // Mock services
    class MockAuthService {
      isAuthenticated = true;
      currentUser = { id: '1', name: 'Test User' };
    
      async login(email: string, password: string) {
        return email === '[email protected]' && password === 'password';
      }
    
      logout() {
        this.isAuthenticated = false;
        this.currentUser = null;
      }
    }
    
    class MockApiService {
      private responses = new Map<string, any>();
    
      setResponse(endpoint: string, data: any) {
        this.responses.set(endpoint, data);
      }
    
      async get(endpoint: string) {
        if (!this.responses.has(endpoint)) {
          throw new Error(`No mock data for ${endpoint}`);
        }
        return this.responses.get(endpoint);
      }
    
      async post(endpoint: string, data: any) {
        return { success: true, data };
      }
    }
    
    class MockNotificationService {
      notifications: Array<{ type: string; message: string }> = [];
    
      success(message: string) {
        this.notifications.push({ type: 'success', message });
      }
    
      error(message: string) {
        this.notifications.push({ type: 'error', message });
      }
    
      clear() {
        this.notifications = [];
      }
    }
  2. Test component with multiple service dependencies:

    import { resolve } from '@aurelia/kernel';
    
    describe('Complex component integration', () => {
      it('should coordinate multiple services for user dashboard', async () => {
        class UserDashboard {
          private auth = resolve(AuthService);
          private api = resolve(ApiService);
          private notifications = resolve(NotificationService);
    
          userData: any = null;
          loading = false;
          error: string | null = null;
    
          async attached() {
            if (!this.auth.isAuthenticated) {
              this.error = 'Not authenticated';
              return;
            }
    
            this.loading = true;
    
            try {
              this.userData = await this.api.get(`/users/${this.auth.currentUser.id}`);
              this.notifications.success('Dashboard loaded');
            } catch (error) {
              this.error = 'Failed to load dashboard';
              this.notifications.error('Failed to load dashboard');
            } finally {
              this.loading = false;
            }
          }
    
          async updateProfile(data: any) {
            try {
              await this.api.post(`/users/${this.auth.currentUser.id}`, data);
              this.notifications.success('Profile updated');
              return true;
            } catch {
              this.notifications.error('Failed to update profile');
              return false;
            }
          }
        }
    
        const mockAuth = new MockAuthService();
        const mockApi = new MockApiService();
        const mockNotifications = new MockNotificationService();
    
        // Configure mock API responses
        mockApi.setResponse('/users/1', {
          id: '1',
          name: 'Test User',
          email: '[email protected]'
        });
    
        const { component, platform } = await createFixture
          .component(UserDashboard)
          .html`<div>
            <div if.bind="loading">Loading...</div>
            <div if.bind="error">\${error}</div>
            <div if.bind="userData">\${userData.name}</div>
          </div>`
          .deps(
            Registration.instance(AuthService, mockAuth),
            Registration.instance(ApiService, mockApi),
            Registration.instance(NotificationService, mockNotifications)
          )
          .build()
          .started;
    
        // Wait for attached() to complete
        await platform.taskQueue.yield();
    
        // Verify data was loaded
        expect(component.userData).toBeTruthy();
        expect(component.userData.name).toBe('Test User');
        expect(component.loading).toBe(false);
        expect(mockNotifications.notifications.length).toBe(1);
        expect(mockNotifications.notifications[0].type).toBe('success');
    
        // Test update
        const success = await component.updateProfile({ name: 'Updated Name' });
        expect(success).toBe(true);
        expect(mockNotifications.notifications.length).toBe(2);
    
        await fixture.stop(true);
      });
    });
  3. Test error scenarios and recovery:

    it('should handle service failures gracefully', async () => {
      class ErrorProneComponent {
        private api = resolve(ApiService);
        private notifications = resolve(NotificationService);
    
        data: any = null;
        error: string | null = null;
    
        async loadData() {
          try {
            this.error = null;
            this.data = await this.api.get('/data');
          } catch (error) {
            this.error = 'Failed to load data';
            this.notifications.error('Failed to load data');
          }
        }
    
        async retry() {
          await this.loadData();
        }
      }
    
      const mockApi = new MockApiService();
      const mockNotifications = new MockNotificationService();
    
      let callCount = 0;
      mockApi.get = async (endpoint: string) => {
        callCount++;
        if (callCount === 1) {
          throw new Error('Service unavailable');
        }
        return { success: true };
      };
    
      const { component, platform } = await createFixture
        .component(ErrorProneComponent)
        .html`<div>
          <button click.trigger="loadData()">Load</button>
          <button if.bind="error" click.trigger="retry()">Retry</button>
        </div>`
        .deps(
          Registration.instance(ApiService, mockApi),
          Registration.instance(NotificationService, mockNotifications)
        )
        .build()
        .started;
    
      // First load fails
      await component.loadData();
      await platform.taskQueue.yield();
    
      expect(component.error).toBeTruthy();
      expect(component.data).toBeNull();
      expect(callCount).toBe(1);
    
      // Retry succeeds
      await component.retry();
      await platform.taskQueue.yield();
    
      expect(component.error).toBeNull();
      expect(component.data).toBeTruthy();
      expect(callCount).toBe(2);
    
      await fixture.stop(true);
    });

Checklist

  • Multiple service dependencies are properly mocked

  • Service interactions are tested in integration

  • Error scenarios are tested and handled

  • Retry/recovery mechanisms work correctly

  • Mock services provide realistic behavior

Testing pattern cheat sheet

Scenario
Key Approach
Tools/APIs

Async API calls

Mock HTTP client + taskQueue.yield()

MockHttpClient, platform.taskQueue.yield()

Router navigation

Router configuration + router.load()

RouterConfiguration, IRouter

Form validation

Validation rules + controller

IValidationRules, IValidationController

Component interaction

Bindables + custom events

@bindable, CustomEvent

Lifecycle hooks

Hook execution + timing

.started, stop(true)

Service dependencies

Mock registrations

Registration.instance(), mock services

Best practices

  1. Always await .started: Ensures all async lifecycle hooks complete

  2. Use platform.taskQueue.yield(): After async operations or state changes

  3. Mock external dependencies: HTTP clients, auth services, APIs

  4. Test error paths: Don't just test happy scenarios

  5. Clean up with stop(true): Prevents memory leaks and interference

  6. Isolate tests: Each test should be independent

  7. Use descriptive test names: Clear "should..." statements

  8. Test user interactions: Clicks, typing, form submission

  9. Verify state changes: Check both component properties and DOM

  10. Test accessibility: Verify ARIA attributes and keyboard navigation

See also

Last updated

Was this helpful?