# SkillPatch skill: karma-skill

This skill generates Karma test runner configurations for browser-based JavaScript and TypeScript testing. It supports multiple frameworks including Jasmine, Mocha, and QUnit, and provides patterns for Angular integration, LambdaTest cloud browser setup, and CI/CD pipelines. It includes ready-to-use karma.conf.js templates, test examples, and setup/run commands.

You (the agent) were given this URL and asked to use this skill. This is a **public** skill — no SkillPatch account, API key, or prior setup is required. Two ways to use it:

**1. Use it right now** — the skill's complete file tree (SKILL.md + all reference files) is inlined below; read `SKILL.md` first, then follow it, consulting the other files as it directs.

**2. Install the exact package onto disk** (recommended if you can run a shell — this reproduces the skill byte-for-byte, including any binary assets that can't be inlined):

```bash
mkdir -p .claude/skills/karma-skill
curl -sSL https://skillpatch.dev/install_skill/karma-skill | tar -xz -C .claude/skills/
```

(`.claude/skills/` is Claude Code's convention; use whatever directory your agent loads skills from.)


---

## Skill files (3)

- `SKILL.md`
- `reference/advanced-patterns.md`
- `reference/playbook.md`


### `SKILL.md`

````markdown
---
name: karma-skill
description: >
  Generates Karma test runner configurations for browser-based JavaScript testing.
  Works with Jasmine, Mocha, or QUnit. Use when user mentions "Karma",
  "karma.conf.js", "browser test runner". Triggers on: "Karma", "karma.conf",
  "karma test runner", "browser-based JS test".
languages:
  - JavaScript
  - TypeScript
category: unit-testing
license: MIT
metadata:
  author: TestMu AI
  version: "1.0"
---

# Karma Testing Skill

## Core Patterns

### karma.conf.js

```javascript
module.exports = function(config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine'],
    files: [
      'src/**/*.js',
      'test/**/*.spec.js'
    ],
    preprocessors: {
      'src/**/*.js': ['coverage']
    },
    reporters: ['progress', 'coverage'],
    coverageReporter: {
      type: 'html',
      dir: 'coverage/'
    },
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['Chrome', 'Firefox'],
    singleRun: false,
    concurrency: Infinity,

    // LambdaTest cloud browsers
    customLaunchers: {
      ChromeLT: {
        base: 'WebDriver',
        config: {
          hostname: 'hub.lambdatest.com',
          port: 80
        },
        browserName: 'Chrome',
        version: 'latest',
        name: 'Karma Test',
        tunnel: true,
        user: process.env.LT_USERNAME,
        accessKey: process.env.LT_ACCESS_KEY
      }
    }
  });
};
```

### Test with Jasmine Framework

```javascript
describe('StringUtils', () => {
  it('should capitalize first letter', () => {
    expect(StringUtils.capitalize('hello')).toBe('Hello');
  });

  it('should handle empty string', () => {
    expect(StringUtils.capitalize('')).toBe('');
  });
});
```

### Angular Integration

```javascript
// karma.conf.js for Angular
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
  require('karma-jasmine'),
  require('karma-chrome-launcher'),
  require('karma-coverage'),
  require('@angular-devkit/build-angular/plugins/karma')
],
```

## Setup: `npm install karma karma-jasmine karma-chrome-launcher karma-coverage --save-dev`
## Run: `npx karma start` or `npx karma start --single-run`
## Init: `npx karma init karma.conf.js`

## Deep Patterns

See `reference/playbook.md` for production-grade patterns:

| Section | What You Get |
|---------|-------------|
| §1 Production Configuration | Full karma.conf.js with coverage thresholds, reporters, CI launchers |
| §2 Component Testing | Service mocking, DOM interaction, form validation patterns |
| §3 HTTP Service Testing | HttpTestingController, error handling, retry testing |
| §4 Directive & Pipe Testing | Host component pattern, custom pipes with edge cases |
| §5 RxJS & Async Patterns | debounceTime, switchMap cancellation, subscription cleanup |
| §6 Router & NgRx Testing | RouterTestingModule, MockStore, selector overrides |
| §7 LambdaTest Integration | Cloud browser configuration for cross-browser testing |
| §8 CI/CD Integration | GitHub Actions with coverage, test reporting |
| §9 Debugging Table | 12 common problems with causes and fixes |
| §10 Best Practices | 14-item checklist for production Angular testing |

````


### `reference/advanced-patterns.md`

````markdown
# Karma — Advanced Patterns & Playbook

## Configuration for Angular

```javascript
// karma.conf.js — production-grade
module.exports = function (config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine', '@angular-devkit/build-angular'],
    plugins: [
      require('karma-jasmine'),
      require('karma-chrome-launcher'),
      require('karma-coverage'),
      require('karma-junit-reporter'),
      require('@angular-devkit/build-angular/plugins/karma')
    ],
    client: { clearContext: false, jasmine: { random: true, seed: '' } },
    coverageReporter: {
      dir: require('path').join(__dirname, './coverage'),
      subdir: '.',
      reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'lcov' }],
      check: { global: { statements: 80, branches: 75, functions: 80, lines: 80 } }
    },
    reporters: ['progress', 'coverage', 'junit'],
    junitReporter: { outputDir: 'reports', outputFile: 'junit.xml' },
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['ChromeHeadless'],
    customLaunchers: {
      ChromeHeadlessCI: { base: 'ChromeHeadless', flags: ['--no-sandbox', '--disable-gpu'] }
    },
    singleRun: true,
    restartOnFileChange: true,
    concurrency: Infinity
  });
};
```

## Angular Component Testing

```typescript
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

describe('UserComponent', () => {
  let component: UserComponent;
  let fixture: ComponentFixture<UserComponent>;
  let httpMock: HttpTestingController;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      declarations: [UserComponent],
      providers: [{ provide: UserService, useClass: MockUserService }]
    }).compileComponents();

    fixture = TestBed.createComponent(UserComponent);
    component = fixture.componentInstance;
    httpMock = TestBed.inject(HttpTestingController);
    fixture.detectChanges();
  });

  afterEach(() => httpMock.verify());

  it('loads user on init', () => {
    const req = httpMock.expectOne('/api/users/1');
    req.flush({ id: 1, name: 'Alice' });
    fixture.detectChanges();
    expect(fixture.nativeElement.querySelector('.name').textContent).toContain('Alice');
  });

  it('handles async with fakeAsync', fakeAsync(() => {
    component.search('test');
    tick(300); // debounce
    fixture.detectChanges();
    expect(component.results.length).toBeGreaterThan(0);
  }));
});
```

## Multi-Browser Testing

```javascript
// karma.conf.js additions for cross-browser
browsers: ['Chrome', 'Firefox', 'Safari'],
customLaunchers: {
  ChromeDebug: { base: 'Chrome', flags: ['--remote-debugging-port=9333'] },
  FirefoxHeadless: { base: 'Firefox', flags: ['-headless'] }
}
```

## Anti-Patterns

- ❌ `singleRun: false` in CI — tests never exit
- ❌ Testing with real HTTP calls — always use `HttpTestingController`
- ❌ Missing `fixture.detectChanges()` after state changes
- ❌ `PhantomJS` launcher — deprecated, use ChromeHeadless

````


### `reference/playbook.md`

````markdown
# Karma — Advanced Playbook

## §1 Production Configuration

### karma.conf.js — Full Setup
```javascript
// karma.conf.js
module.exports = function (config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine', '@angular-devkit/build-angular'],

    plugins: [
      require('karma-jasmine'),
      require('karma-chrome-launcher'),
      require('karma-firefox-launcher'),
      require('karma-coverage'),
      require('karma-junit-reporter'),
      require('karma-spec-reporter'),
      require('@angular-devkit/build-angular/plugins/karma'),
    ],

    client: {
      jasmine: {
        random: true,
        seed: '',
        stopOnSpecFailure: false,
        failSpecWithNoExpectations: true,
        forbidDuplicateNames: true,
        timeoutInterval: 10000,
      },
      clearContext: false,
    },

    coverageReporter: {
      dir: require('path').join(__dirname, './coverage'),
      subdir: '.',
      reporters: [
        { type: 'html' },
        { type: 'text-summary' },
        { type: 'lcov' },
        { type: 'cobertura' },
      ],
      check: {
        global: {
          statements: 80,
          branches: 75,
          functions: 80,
          lines: 80,
        },
        each: {
          statements: 50,
          branches: 50,
          functions: 50,
          lines: 50,
        },
      },
    },

    reporters: ['spec', 'coverage', 'junit'],
    junitReporter: {
      outputDir: 'test-results',
      outputFile: 'karma-results.xml',
      useBrowserName: false,
    },

    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    restartOnFileChange: true,

    browsers: [process.env.CI ? 'ChromeHeadlessNoSandbox' : 'Chrome'],
    customLaunchers: {
      ChromeHeadlessNoSandbox: {
        base: 'ChromeHeadless',
        flags: [
          '--no-sandbox',
          '--disable-gpu',
          '--disable-translate',
          '--disable-extensions',
        ],
      },
    },

    singleRun: !!process.env.CI,
    browserDisconnectTolerance: 3,
    browserDisconnectTimeout: 30000,
    browserNoActivityTimeout: 60000,
    captureTimeout: 60000,
    concurrency: Infinity,
  });
};
```

### tsconfig.spec.json (Angular)
```json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "types": ["jasmine"]
  },
  "files": ["src/test.ts", "src/polyfills.ts"],
  "include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}
```

---

## §2 Angular Component Testing — Production Patterns

### Service Mocking with createSpyObj
```typescript
import { ComponentFixture, TestBed, fakeAsync, tick, flush } from '@angular/core/testing';
import { ReactiveFormsModule, FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { of, throwError, BehaviorSubject } from 'rxjs';
import { LoginComponent } from './login.component';
import { AuthService } from '../services/auth.service';
import { NotificationService } from '../services/notification.service';

describe('LoginComponent', () => {
  let component: LoginComponent;
  let fixture: ComponentFixture<LoginComponent>;
  let authService: jasmine.SpyObj<AuthService>;
  let router: jasmine.SpyObj<Router>;
  let notifyService: jasmine.SpyObj<NotificationService>;

  beforeEach(async () => {
    authService = jasmine.createSpyObj('AuthService', ['login', 'logout', 'isAuthenticated'], {
      currentUser$: new BehaviorSubject(null),
    });
    router = jasmine.createSpyObj('Router', ['navigate']);
    notifyService = jasmine.createSpyObj('NotificationService', ['success', 'error']);

    await TestBed.configureTestingModule({
      imports: [ReactiveFormsModule, FormsModule],
      declarations: [LoginComponent],
      providers: [
        { provide: AuthService, useValue: authService },
        { provide: Router, useValue: router },
        { provide: NotificationService, useValue: notifyService },
      ],
    }).compileComponents();

    fixture = TestBed.createComponent(LoginComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  describe('form validation', () => {
    it('should be invalid when empty', () => {
      expect(component.loginForm.valid).toBeFalse();
    });

    it('should validate email format', () => {
      component.loginForm.patchValue({ email: 'invalid', password: 'pass123' });
      expect(component.loginForm.get('email')!.errors?.['email']).toBeTruthy();
    });

    it('should be valid with correct data', () => {
      component.loginForm.patchValue({
        email: 'user@example.com',
        password: 'password123',
      });
      expect(component.loginForm.valid).toBeTrue();
    });
  });

  describe('login submission', () => {
    beforeEach(() => {
      component.loginForm.patchValue({
        email: 'user@example.com',
        password: 'password123',
      });
    });

    it('should call auth service and navigate on success', fakeAsync(() => {
      authService.login.and.returnValue(of({ token: 'abc', user: { id: 1 } }));
      component.onSubmit();
      tick();
      expect(authService.login).toHaveBeenCalledWith('user@example.com', 'password123');
      expect(router.navigate).toHaveBeenCalledWith(['/dashboard']);
      expect(notifyService.success).toHaveBeenCalledWith('Login successful');
    }));

    it('should show error message on failure', fakeAsync(() => {
      authService.login.and.returnValue(
        throwError(() => ({ status: 401, error: { message: 'Invalid credentials' } }))
      );
      component.onSubmit();
      tick();
      fixture.detectChanges();

      const errorEl = fixture.nativeElement.querySelector('[data-testid="error-message"]');
      expect(errorEl.textContent).toContain('Invalid credentials');
      expect(component.isLoading).toBeFalse();
    }));

    it('should disable submit button while loading', fakeAsync(() => {
      authService.login.and.returnValue(of({ token: 'abc' }).pipe());
      component.onSubmit();
      fixture.detectChanges();

      const button = fixture.nativeElement.querySelector('button[type="submit"]');
      expect(button.disabled).toBeTrue();
      expect(component.isLoading).toBeTrue();
      tick();
    }));
  });
});
```

### DOM Interaction Patterns
```typescript
describe('ProductListComponent', () => {
  let component: ProductListComponent;
  let fixture: ComponentFixture<ProductListComponent>;
  let productService: jasmine.SpyObj<ProductService>;

  const mockProducts = [
    { id: 1, name: 'Widget', price: 9.99, inStock: true },
    { id: 2, name: 'Gadget', price: 19.99, inStock: false },
    { id: 3, name: 'Doohickey', price: 4.99, inStock: true },
  ];

  beforeEach(async () => {
    productService = jasmine.createSpyObj('ProductService', ['getAll', 'delete']);
    productService.getAll.and.returnValue(of(mockProducts));

    await TestBed.configureTestingModule({
      declarations: [ProductListComponent],
      providers: [{ provide: ProductService, useValue: productService }],
    }).compileComponents();

    fixture = TestBed.createComponent(ProductListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should render product cards', () => {
    const cards = fixture.nativeElement.querySelectorAll('[data-testid="product-card"]');
    expect(cards.length).toBe(3);
  });

  it('should show out-of-stock badge', () => {
    const badges = fixture.nativeElement.querySelectorAll('.out-of-stock-badge');
    expect(badges.length).toBe(1);
    expect(badges[0].textContent.trim()).toBe('Out of Stock');
  });

  it('should sort products by price', () => {
    const sortButton = fixture.nativeElement.querySelector('[data-testid="sort-price"]');
    sortButton.click();
    fixture.detectChanges();

    const prices = Array.from(
      fixture.nativeElement.querySelectorAll('[data-testid="product-price"]')
    ).map((el: any) => parseFloat(el.textContent.replace('$', '')));

    expect(prices).toEqual([4.99, 9.99, 19.99]);
  });

  it('should confirm before deleting', fakeAsync(() => {
    spyOn(window, 'confirm').and.returnValue(true);
    productService.delete.and.returnValue(of(void 0));

    const deleteBtn = fixture.nativeElement.querySelector('[data-testid="delete-btn-1"]');
    deleteBtn.click();
    tick();

    expect(window.confirm).toHaveBeenCalled();
    expect(productService.delete).toHaveBeenCalledWith(1);
  }));
});
```

---

## §3 Service Testing with HTTP

### HttpClient Testing
```typescript
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UserService } from './user.service';

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

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [UserService],
    });
    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });

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

  it('should fetch users with pagination', () => {
    const mockResponse = {
      data: [{ id: 1, name: 'Alice' }],
      total: 50,
      page: 1,
    };

    service.getUsers(1, 10).subscribe((response) => {
      expect(response.data.length).toBe(1);
      expect(response.total).toBe(50);
    });

    const req = httpMock.expectOne('/api/users?page=1&limit=10');
    expect(req.request.method).toBe('GET');
    expect(req.request.headers.get('Accept')).toBe('application/json');
    req.flush(mockResponse);
  });

  it('should handle 404 errors', () => {
    service.getUser(999).subscribe({
      error: (err) => {
        expect(err.status).toBe(404);
        expect(err.statusText).toBe('Not Found');
      },
    });

    const req = httpMock.expectOne('/api/users/999');
    req.flush('Not found', { status: 404, statusText: 'Not Found' });
  });

  it('should retry failed requests', () => {
    service.getUsersWithRetry().subscribe((users) => {
      expect(users.length).toBe(1);
    });

    // First attempt fails
    const req1 = httpMock.expectOne('/api/users');
    req1.error(new ProgressEvent('Network error'));

    // Retry succeeds
    const req2 = httpMock.expectOne('/api/users');
    req2.flush([{ id: 1, name: 'Alice' }]);
  });

  it('should send POST with correct body', () => {
    const newUser = { name: 'Bob', email: 'bob@test.com' };

    service.createUser(newUser).subscribe((user) => {
      expect(user.id).toBeDefined();
    });

    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('POST');
    expect(req.request.body).toEqual(newUser);
    req.flush({ id: 2, ...newUser });
  });
});
```

---

## §4 Directive & Pipe Testing

### Custom Directive
```typescript
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HighlightDirective } from './highlight.directive';

@Component({
  template: `
    <p appHighlight="yellow" data-testid="highlighted">Highlighted text</p>
    <p data-testid="plain">Plain text</p>
  `,
})
class TestHostComponent {}

describe('HighlightDirective', () => {
  let fixture: ComponentFixture<TestHostComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [TestHostComponent, HighlightDirective],
    });
    fixture = TestBed.createComponent(TestHostComponent);
    fixture.detectChanges();
  });

  it('should apply background color', () => {
    const el = fixture.nativeElement.querySelector('[data-testid="highlighted"]');
    expect(el.style.backgroundColor).toBe('yellow');
  });

  it('should change color on hover', () => {
    const el = fixture.nativeElement.querySelector('[data-testid="highlighted"]');
    el.dispatchEvent(new Event('mouseenter'));
    fixture.detectChanges();
    expect(el.style.backgroundColor).toBe('gold');
  });
});
```

### Custom Pipe
```typescript
import { TruncatePipe } from './truncate.pipe';

describe('TruncatePipe', () => {
  const pipe = new TruncatePipe();

  it('should truncate long strings', () => {
    expect(pipe.transform('Hello World!', 5)).toBe('Hello...');
  });

  it('should return short strings unchanged', () => {
    expect(pipe.transform('Hi', 10)).toBe('Hi');
  });

  it('should handle null/undefined', () => {
    expect(pipe.transform(null as any)).toBe('');
    expect(pipe.transform(undefined as any)).toBe('');
  });

  it('should use custom suffix', () => {
    expect(pipe.transform('Hello World!', 5, ' [more]')).toBe('Hello [more]');
  });
});
```

---

## §5 RxJS & Async Patterns

### Testing Observables
```typescript
import { fakeAsync, tick, flush, discardPeriodicTimers } from '@angular/core/testing';
import { of, interval, Subject, timer } from 'rxjs';
import { debounceTime, switchMap, take, delay } from 'rxjs/operators';

describe('SearchComponent', () => {
  let component: SearchComponent;
  let fixture: ComponentFixture<SearchComponent>;
  let searchService: jasmine.SpyObj<SearchService>;

  beforeEach(async () => {
    searchService = jasmine.createSpyObj('SearchService', ['search']);
    await TestBed.configureTestingModule({
      declarations: [SearchComponent],
      providers: [{ provide: SearchService, useValue: searchService }],
    }).compileComponents();

    fixture = TestBed.createComponent(SearchComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should debounce search input', fakeAsync(() => {
    searchService.search.and.returnValue(of([{ id: 1, name: 'Result' }]));

    component.searchControl.setValue('hel');
    tick(100);
    component.searchControl.setValue('hello');
    tick(300); // debounceTime(300)

    expect(searchService.search).toHaveBeenCalledTimes(1);
    expect(searchService.search).toHaveBeenCalledWith('hello');

    discardPeriodicTimers();
  }));

  it('should cancel previous search on new input', fakeAsync(() => {
    const slow$ = of([{ id: 1 }]).pipe(delay(500));
    const fast$ = of([{ id: 2 }]);
    searchService.search.and.returnValues(slow$, fast$);

    component.searchControl.setValue('first');
    tick(300);
    component.searchControl.setValue('second');
    tick(300);
    tick(500);

    expect(component.results.length).toBe(1);
    expect(component.results[0].id).toBe(2);

    discardPeriodicTimers();
  }));

  it('should unsubscribe on destroy', () => {
    const subject = new Subject();
    spyOn(subject, 'unsubscribe');
    component['subscription'] = subject.subscribe();
    component.ngOnDestroy();
    expect(component['subscription'].closed).toBeTrue();
  });
});
```

---

## §6 Router & NgRx Testing

### Router Testing
```typescript
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
import { Location } from '@angular/common';

describe('AppComponent routing', () => {
  let router: Router;
  let location: Location;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [
        RouterTestingModule.withRoutes([
          { path: '', component: HomeComponent },
          { path: 'dashboard', component: DashboardComponent, canActivate: [AuthGuard] },
          { path: '**', component: NotFoundComponent },
        ]),
      ],
      declarations: [AppComponent, HomeComponent, DashboardComponent, NotFoundComponent],
      providers: [{ provide: AuthGuard, useValue: { canActivate: () => true } }],
    }).compileComponents();

    router = TestBed.inject(Router);
    location = TestBed.inject(Location);
    router.initialNavigation();
  });

  it('should navigate to dashboard', fakeAsync(() => {
    router.navigate(['/dashboard']);
    tick();
    expect(location.path()).toBe('/dashboard');
  }));

  it('should redirect unknown routes to 404', fakeAsync(() => {
    router.navigate(['/nonexistent']);
    tick();
    expect(location.path()).toBe('/nonexistent');
  }));
});
```

### NgRx Store Testing
```typescript
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import { Store } from '@ngrx/store';
import * as UserActions from '../store/user.actions';
import * as UserSelectors from '../store/user.selectors';

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

  const initialState = {
    users: {
      list: [],
      loading: false,
      error: null,
    },
  };

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [UserListComponent],
      providers: [
        provideMockStore({
          initialState,
          selectors: [
            { selector: UserSelectors.selectAllUsers, value: [] },
            { selector: UserSelectors.selectLoading, value: false },
          ],
        }),
      ],
    }).compileComponents();

    store = TestBed.inject(Store) as MockStore;
    spyOn(store, 'dispatch');
    fixture = TestBed.createComponent(UserListComponent);
    fixture.detectChanges();
  });

  it('should dispatch loadUsers on init', () => {
    expect(store.dispatch).toHaveBeenCalledWith(UserActions.loadUsers());
  });

  it('should show loading spinner', () => {
    store.overrideSelector(UserSelectors.selectLoading, true);
    store.refreshState();
    fixture.detectChanges();

    const spinner = fixture.nativeElement.querySelector('[data-testid="loading"]');
    expect(spinner).toBeTruthy();
  });

  it('should render users from store', () => {
    store.overrideSelector(UserSelectors.selectAllUsers, [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ]);
    store.refreshState();
    fixture.detectChanges();

    const rows = fixture.nativeElement.querySelectorAll('[data-testid="user-row"]');
    expect(rows.length).toBe(2);
  });
});
```

---

## §7 LambdaTest Cloud Integration

```javascript
// karma.conf.js — LambdaTest cloud browsers
module.exports = function (config) {
  const customLaunchers = {
    lt_chrome_win: {
      base: 'WebDriver',
      config: { hostname: 'hub.lambdatest.com', port: 80 },
      browserName: 'Chrome',
      version: 'latest',
      platform: 'Windows 11',
      name: 'Karma Chrome Win',
      tunnel: true,
      tunnelName: 'karma-tunnel',
      user: process.env.LT_USERNAME,
      accessKey: process.env.LT_ACCESS_KEY,
      video: true,
      console: true,
    },
    lt_firefox_mac: {
      base: 'WebDriver',
      config: { hostname: 'hub.lambdatest.com', port: 80 },
      browserName: 'Firefox',
      version: 'latest',
      platform: 'macOS Sonoma',
      name: 'Karma Firefox Mac',
      tunnel: true,
      user: process.env.LT_USERNAME,
      accessKey: process.env.LT_ACCESS_KEY,
    },
  };

  config.set({
    browsers: Object.keys(customLaunchers),
    customLaunchers,
    concurrency: 2,
    browserDisconnectTimeout: 60000,
    browserNoActivityTimeout: 120000,
  });
};
```

---

## §8 CI/CD Integration

### GitHub Actions
```yaml
name: Angular Tests
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci

      - run: npx ng test --watch=false --code-coverage --browsers=ChromeHeadlessNoSandbox
        env:
          CI: true

      - name: Upload Coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

      - name: Check Coverage Threshold
        run: |
          COVERAGE=$(cat coverage/text-summary.txt | grep 'Lines' | grep -oP '[\d.]+%' | head -1)
          echo "Line coverage: $COVERAGE"

      - name: Publish Test Results
        uses: dorny/test-reporter@v1
        if: always()
        with:
          name: Karma Test Results
          path: test-results/karma-results.xml
          reporter: 
...<truncated>
````
