bookworm-smart-assistant/skills/angular-architect/references/testing.md

11 KiB

Angular Testing

Component Testing

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { UserListComponent } from './user-list.component';
import { UsersService } from './users.service';
import { of } from 'rxjs';

describe('UserListComponent', () => {
  let component: UserListComponent;
  let fixture: ComponentFixture<UserListComponent>;
  let usersService: jasmine.SpyObj<UsersService>;

  const mockUsers = [
    { id: '1', name: 'John Doe', email: 'john@example.com' },
    { id: '2', name: 'Jane Smith', email: 'jane@example.com' }
  ];

  beforeEach(async () => {
    // Create spy object
    const usersServiceSpy = jasmine.createSpyObj('UsersService', ['getAll', 'delete']);

    await TestBed.configureTestingModule({
      imports: [UserListComponent],  // Standalone component
      providers: [
        { provide: UsersService, useValue: usersServiceSpy }
      ]
    }).compileComponents();

    usersService = TestBed.inject(UsersService) as jasmine.SpyObj<UsersService>;
    fixture = TestBed.createComponent(UserListComponent);
    component = fixture.componentInstance;
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should load users on init', () => {
    usersService.getAll.and.returnValue(of(mockUsers));

    fixture.detectChanges();  // Trigger ngOnInit

    expect(usersService.getAll).toHaveBeenCalled();
    expect(component.users()).toEqual(mockUsers);
  });

  it('should display users in template', () => {
    component.users.set(mockUsers);
    fixture.detectChanges();

    const compiled = fixture.nativeElement;
    const userElements = compiled.querySelectorAll('.user-item');

    expect(userElements.length).toBe(2);
    expect(userElements[0].textContent).toContain('John Doe');
  });

  it('should emit userSelected when user clicked', () => {
    const emitSpy = spyOn(component.userSelected, 'emit');

    component.onUserClick(mockUsers[0]);

    expect(emitSpy).toHaveBeenCalledWith(mockUsers[0]);
  });

  it('should show loading state', () => {
    component.loading.set(true);
    fixture.detectChanges();

    const compiled = fixture.nativeElement;
    expect(compiled.querySelector('.loading')).toBeTruthy();
  });
});

Service Testing

import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UsersService } from './users.service';
import { User } from './user.model';

describe('UsersService', () => {
  let service: UsersService;
  let httpMock: HttpTestingController;

  const mockUsers: User[] = [
    { id: '1', name: 'John', email: 'john@example.com' },
    { id: '2', name: 'Jane', email: 'jane@example.com' }
  ];

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [UsersService]
    });

    service = TestBed.inject(UsersService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify();  // Verify no outstanding requests
  });

  it('should fetch all users', (done) => {
    service.getAll().subscribe(users => {
      expect(users).toEqual(mockUsers);
      done();
    });

    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('GET');
    req.flush(mockUsers);
  });

  it('should create a user', (done) => {
    const newUser: User = { id: '3', name: 'Bob', email: 'bob@example.com' };

    service.create(newUser).subscribe(user => {
      expect(user).toEqual(newUser);
      done();
    });

    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('POST');
    expect(req.request.body).toEqual(newUser);
    req.flush(newUser);
  });

  it('should handle error', (done) => {
    service.getAll().subscribe({
      next: () => fail('should have failed'),
      error: (error) => {
        expect(error.status).toBe(500);
        done();
      }
    });

    const req = httpMock.expectOne('/api/users');
    req.flush('Server error', { status: 500, statusText: 'Internal Server Error' });
  });
});

RxJS Marble Testing

import { TestScheduler } from 'rxjs/testing';
import { delay, map } from 'rxjs/operators';

describe('RxJS Operators', () => {
  let testScheduler: TestScheduler;

  beforeEach(() => {
    testScheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  it('should map values', () => {
    testScheduler.run(({ cold, expectObservable }) => {
      const source$ = cold('--a--b--c--|', { a: 1, b: 2, c: 3 });
      const expected = '    --x--y--z--|';
      const result$ = source$.pipe(map(x => x * 10));

      expectObservable(result$).toBe(expected, { x: 10, y: 20, z: 30 });
    });
  });

  it('should delay emissions', () => {
    testScheduler.run(({ cold, expectObservable }) => {
      const source$ = cold('--a--b--|', { a: 1, b: 2 });
      const expected = '    ----a--b--|';
      const result$ = source$.pipe(delay(20));

      expectObservable(result$).toBe(expected, { a: 1, b: 2 });
    });
  });
});

Testing with Signals

import { signal } from '@angular/core';

describe('Counter Component', () => {
  it('should update signal value', () => {
    const count = signal(0);

    expect(count()).toBe(0);

    count.set(5);
    expect(count()).toBe(5);

    count.update(val => val + 1);
    expect(count()).toBe(6);
  });

  it('should compute derived value', () => {
    const count = signal(5);
    const doubled = computed(() => count() * 2);

    expect(doubled()).toBe(10);

    count.set(10);
    expect(doubled()).toBe(20);
  });
});

Testing NgRx

import { TestBed } from '@angular/core/testing';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import { UsersComponent } from './users.component';
import { selectAllUsers, selectUsersLoading } from './store/users.selectors';

describe('UsersComponent with NgRx', () => {
  let component: UsersComponent;
  let fixture: ComponentFixture<UsersComponent>;
  let store: MockStore;

  const initialState = {
    users: {
      ids: ['1', '2'],
      entities: {
        '1': { id: '1', name: 'John' },
        '2': { id: '2', name: 'Jane' }
      },
      loading: false
    }
  };

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [UsersComponent],
      providers: [
        provideMockStore({ initialState })
      ]
    }).compileComponents();

    store = TestBed.inject(MockStore);
    fixture = TestBed.createComponent(UsersComponent);
    component = fixture.componentInstance;
  });

  it('should select users from store', () => {
    store.overrideSelector(selectAllUsers, [
      { id: '1', name: 'John' },
      { id: '2', name: 'Jane' }
    ]);

    fixture.detectChanges();

    expect(component.users().length).toBe(2);
  });

  it('should dispatch action on delete', () => {
    const dispatchSpy = spyOn(store, 'dispatch');

    component.onDelete('1');

    expect(dispatchSpy).toHaveBeenCalledWith(
      UsersActions.deleteUser({ id: '1' })
    );
  });
});

Testing Effects

import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Observable, of, throwError } from 'rxjs';
import { UsersEffects } from './users.effects';
import { UsersService } from './users.service';
import { UsersActions } from './users.actions';
import { hot, cold } from 'jasmine-marbles';

describe('UsersEffects', () => {
  let actions$: Observable<any>;
  let effects: UsersEffects;
  let usersService: jasmine.SpyObj<UsersService>;

  beforeEach(() => {
    const usersServiceSpy = jasmine.createSpyObj('UsersService', ['getAll']);

    TestBed.configureTestingModule({
      providers: [
        UsersEffects,
        provideMockActions(() => actions$),
        { provide: UsersService, useValue: usersServiceSpy }
      ]
    });

    effects = TestBed.inject(UsersEffects);
    usersService = TestBed.inject(UsersService) as jasmine.SpyObj<UsersService>;
  });

  it('should load users successfully', () => {
    const users = [{ id: '1', name: 'John' }];
    const action = UsersActions.loadUsers();
    const outcome = UsersActions.loadUsersSuccess({ users });

    actions$ = hot('-a', { a: action });
    const response = cold('-b|', { b: users });
    const expected = cold('--c', { c: outcome });

    usersService.getAll.and.returnValue(response);

    expect(effects.loadUsers$).toBeObservable(expected);
  });

  it('should handle load users failure', () => {
    const action = UsersActions.loadUsers();
    const error = new Error('Failed to load');
    const outcome = UsersActions.loadUsersFailure({ error: error.message });

    actions$ = hot('-a', { a: action });
    const response = cold('-#|', {}, error);
    const expected = cold('--c', { c: outcome });

    usersService.getAll.and.returnValue(response);

    expect(effects.loadUsers$).toBeObservable(expected);
  });
});

Testing Guards

import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { authGuard } from './auth.guard';
import { AuthService } from './auth.service';

describe('authGuard', () => {
  let authService: jasmine.SpyObj<AuthService>;
  let router: jasmine.SpyObj<Router>;

  beforeEach(() => {
    const authServiceSpy = jasmine.createSpyObj('AuthService', ['isAuthenticated']);
    const routerSpy = jasmine.createSpyObj('Router', ['createUrlTree']);

    TestBed.configureTestingModule({
      providers: [
        { provide: AuthService, useValue: authServiceSpy },
        { provide: Router, useValue: routerSpy }
      ]
    });

    authService = TestBed.inject(AuthService) as jasmine.SpyObj<AuthService>;
    router = TestBed.inject(Router) as jasmine.SpyObj<Router>;
  });

  it('should allow access when authenticated', () => {
    authService.isAuthenticated.and.returnValue(true);

    const result = TestBed.runInInjectionContext(() =>
      authGuard({} as any, {} as any)
    );

    expect(result).toBe(true);
  });

  it('should redirect when not authenticated', () => {
    authService.isAuthenticated.and.returnValue(false);
    const urlTree = {} as any;
    router.createUrlTree.and.returnValue(urlTree);

    const result = TestBed.runInInjectionContext(() =>
      authGuard({} as any, { url: '/protected' } as any)
    );

    expect(result).toBe(urlTree);
    expect(router.createUrlTree).toHaveBeenCalledWith(
      ['/login'],
      { queryParams: { returnUrl: '/protected' } }
    );
  });
});

Quick Reference

Test Type Key Tools
Component TestBed, ComponentFixture, detectChanges()
Service HttpClientTestingModule, HttpTestingController
RxJS TestScheduler, marble diagrams
NgRx Store provideMockStore, MockStore
Effects provideMockActions, jasmine-marbles
Guards TestBed.runInInjectionContext()
Signals Direct value checks with ()
Spies jasmine.createSpyObj(), spyOn()