Files
gh-ehssanatassi-angular-mar…/skills/testing-strategies/SKILL.md
2025-11-29 18:25:05 +08:00

1095 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Angular Testing Strategies
Comprehensive guide to testing strategies, patterns, and best practices for Angular applications.
## Table of Contents
1. [Testing Pyramid](#testing-pyramid)
2. [Unit Testing Strategies](#unit-testing-strategies)
3. [Integration Testing](#integration-testing)
4. [E2E Testing](#e2e-testing)
5. [Test Organization](#test-organization)
6. [AAA Pattern](#aaa-pattern)
7. [Test Doubles](#test-doubles)
8. [Common Anti-Patterns](#common-anti-patterns)
9. [Performance Optimization](#performance-optimization)
10. [CI/CD Integration](#cicd-integration)
---
## Testing Pyramid
The ideal test distribution for Angular applications:
```
╱╲
E2E ╲ (5% - Critical user journeys)
╱────────╲
Integration╲ (15% - Component + Service)
╱──────────────╲
Unit Tests ╲ (80% - Pure logic, services)
╱──────────────────╲
```
### Why This Distribution?
- **Unit Tests (80%)**: Fast, isolated, easy to maintain
- **Integration Tests (15%)**: Test component interactions
- **E2E Tests (5%)**: Slow but verify complete user flows
### Cost vs Coverage
| Test Type | Speed | Cost | Confidence | Maintenance |
|-----------|-------|------|------------|-------------|
| Unit | ⚡⚡⚡ | 💰 | ⭐⭐ | ✅ Easy |
| Integration | ⚡⚡ | 💰💰 | ⭐⭐⭐ | ⚠️ Medium |
| E2E | ⚡ | 💰💰💰 | ⭐⭐⭐⭐ | ❌ Hard |
---
## Unit Testing Strategies
### What to Unit Test
**DO Test**:
- Pure functions and business logic
- Service methods
- Component methods (isolated)
- Pipes and custom validators
- Utility functions
- State management (reducers, selectors)
**DON'T Test**:
- Angular framework internals
- Third-party libraries
- Simple getters/setters
- Generated code
### Component Testing Approaches
#### 1. Shallow Testing (Isolated)
Test component logic without rendering:
```typescript
describe('UserProfileComponent (Shallow)', () => {
let component: UserProfileComponent;
let userService: jasmine.SpyObj<UserService>;
beforeEach(() => {
userService = jasmine.createSpyObj('UserService', ['getUser', 'updateUser']);
component = new UserProfileComponent(userService);
});
it('should call updateUser with correct data', () => {
const userData = { name: 'John', email: 'john@example.com' };
userService.updateUser.and.returnValue(of({ success: true }));
component.saveProfile(userData);
expect(userService.updateUser).toHaveBeenCalledWith(userData);
});
});
```
**Pros**: Fast, focused on logic
**Cons**: Doesn't test template integration
**Use for**: Complex business logic, services
#### 2. Deep Testing (TestBed)
Test component with template and dependencies:
```typescript
describe('UserProfileComponent (Deep)', () => {
let component: UserProfileComponent;
let fixture: ComponentFixture<UserProfileComponent>;
let userService: jasmine.SpyObj<UserService>;
beforeEach(async () => {
const spy = jasmine.createSpyObj('UserService', ['getUser', 'updateUser']);
await TestBed.configureTestingModule({
declarations: [ UserProfileComponent ],
imports: [ ReactiveFormsModule, HttpClientTestingModule ],
providers: [
{ provide: UserService, useValue: spy }
]
}).compileComponents();
userService = TestBed.inject(UserService) as jasmine.SpyObj<UserService>;
fixture = TestBed.createComponent(UserProfileComponent);
component = fixture.componentInstance;
});
it('should display user name in template', () => {
const userData = { name: 'John Doe', email: 'john@example.com' };
userService.getUser.and.returnValue(of(userData));
fixture.detectChanges(); // Trigger change detection
const compiled = fixture.nativeElement;
const nameElement = compiled.querySelector('.user-name');
expect(nameElement.textContent).toContain('John Doe');
});
});
```
**Pros**: Tests template + logic integration
**Cons**: Slower, more complex setup
**Use for**: UI interactions, template binding
### Service Testing Patterns
#### Simple Service (No HTTP)
```typescript
describe('CalculatorService', () => {
let service: CalculatorService;
beforeEach(() => {
service = new CalculatorService();
});
it('should add two numbers', () => {
expect(service.add(2, 3)).toBe(5);
});
it('should throw error for division by zero', () => {
expect(() => service.divide(10, 0)).toThrow();
});
});
```
#### HTTP Service Testing
```typescript
describe('UserApiService', () => {
let service: UserApiService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [UserApiService]
});
service = TestBed.inject(UserApiService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Ensure no outstanding requests
});
it('should fetch users', () => {
const mockUsers = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
];
service.getUsers().subscribe(users => {
expect(users).toEqual(mockUsers);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush(mockUsers);
});
it('should handle HTTP errors', () => {
service.getUsers().subscribe({
next: () => fail('should have failed'),
error: (error) => {
expect(error.status).toBe(500);
expect(error.statusText).toBe('Server Error');
}
});
const req = httpMock.expectOne('/api/users');
req.flush('Error', { status: 500, statusText: 'Server Error' });
});
});
```
#### Service with Dependencies
```typescript
describe('AuthService', () => {
let service: AuthService;
let http: jasmine.SpyObj<HttpClient>;
let router: jasmine.SpyObj<Router>;
beforeEach(() => {
const httpSpy = jasmine.createSpyObj('HttpClient', ['post', 'get']);
const routerSpy = jasmine.createSpyObj('Router', ['navigate']);
TestBed.configureTestingModule({
providers: [
AuthService,
{ provide: HttpClient, useValue: httpSpy },
{ provide: Router, useValue: routerSpy }
]
});
service = TestBed.inject(AuthService);
http = TestBed.inject(HttpClient) as jasmine.SpyObj<HttpClient>;
router = TestBed.inject(Router) as jasmine.SpyObj<Router>;
});
it('should login and navigate to dashboard', (done) => {
const mockResponse = { token: 'fake-token' };
http.post.and.returnValue(of(mockResponse));
service.login('test@example.com', 'password').subscribe(() => {
expect(http.post).toHaveBeenCalledWith(
'/api/auth/login',
{ email: 'test@example.com', password: 'password' }
);
expect(router.navigate).toHaveBeenCalledWith(['/dashboard']);
done();
});
});
});
```
### Pipe Testing
```typescript
describe('FilterPipe', () => {
let pipe: FilterPipe;
beforeEach(() => {
pipe = new FilterPipe();
});
it('should filter array by search term', () => {
const items = [
{ name: 'Apple' },
{ name: 'Banana' },
{ name: 'Orange' }
];
const result = pipe.transform(items, 'app', 'name');
expect(result.length).toBe(1);
expect(result[0].name).toBe('Apple');
});
it('should return empty array when no matches', () => {
const items = [{ name: 'Apple' }];
const result = pipe.transform(items, 'xyz', 'name');
expect(result.length).toBe(0);
});
it('should return original array when search is empty', () => {
const items = [{ name: 'Apple' }, { name: 'Banana' }];
const result = pipe.transform(items, '', 'name');
expect(result).toEqual(items);
});
});
```
### Directive Testing
```typescript
describe('HighlightDirective', () => {
let fixture: ComponentFixture<TestComponent>;
let element: DebugElement;
@Component({
template: '<div appHighlight="yellow">Test</div>'
})
class TestComponent {}
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ HighlightDirective, TestComponent ]
}).compileComponents();
fixture = TestBed.createComponent(TestComponent);
element = fixture.debugElement.query(By.directive(HighlightDirective));
fixture.detectChanges();
});
it('should apply background color', () => {
const bgColor = element.nativeElement.style.backgroundColor;
expect(bgColor).toBe('yellow');
});
it('should change color on hover', () => {
element.nativeElement.dispatchEvent(new MouseEvent('mouseenter'));
fixture.detectChanges();
expect(element.nativeElement.style.backgroundColor).toBe('orange');
});
});
```
---
## Integration Testing
### Component + Service Integration
```typescript
describe('ProductListComponent Integration', () => {
let component: ProductListComponent;
let fixture: ComponentFixture<ProductListComponent>;
let productService: ProductService;
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ProductListComponent ],
imports: [ HttpClientTestingModule, CommonModule ],
providers: [ ProductService ]
}).compileComponents();
fixture = TestBed.createComponent(ProductListComponent);
component = fixture.componentInstance;
productService = TestBed.inject(ProductService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should load and display products', () => {
const mockProducts = [
{ id: 1, name: 'Product 1', price: 100 },
{ id: 2, name: 'Product 2', price: 200 }
];
fixture.detectChanges(); // Trigger ngOnInit
const req = httpMock.expectOne('/api/products');
req.flush(mockProducts);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const productElements = compiled.querySelectorAll('.product-item');
expect(productElements.length).toBe(2);
expect(productElements[0].textContent).toContain('Product 1');
});
it('should handle loading state', () => {
expect(component.isLoading).toBeFalsy();
fixture.detectChanges(); // Trigger ngOnInit
expect(component.isLoading).toBeTruthy();
const req = httpMock.expectOne('/api/products');
req.flush([]);
expect(component.isLoading).toBeFalsy();
});
});
```
### Router + Guard Integration
```typescript
describe('AuthGuard Integration', () => {
let guard: AuthGuard;
let authService: jasmine.SpyObj<AuthService>;
let router: Router;
let location: Location;
beforeEach(async () => {
const authServiceSpy = jasmine.createSpyObj('AuthService', ['isAuthenticated']);
await TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes([
{ path: 'dashboard', component: DummyComponent, canActivate: [AuthGuard] },
{ path: 'login', component: DummyComponent }
])],
providers: [
AuthGuard,
{ provide: AuthService, useValue: authServiceSpy }
]
}).compileComponents();
guard = TestBed.inject(AuthGuard);
authService = TestBed.inject(AuthService) as jasmine.SpyObj<AuthService>;
router = TestBed.inject(Router);
location = TestBed.inject(Location);
});
it('should allow navigation when authenticated', fakeAsync(() => {
authService.isAuthenticated.and.returnValue(true);
router.navigate(['/dashboard']);
tick();
expect(location.path()).toBe('/dashboard');
}));
it('should redirect to login when not authenticated', fakeAsync(() => {
authService.isAuthenticated.and.returnValue(false);
router.navigate(['/dashboard']);
tick();
expect(location.path()).toBe('/login');
}));
});
```
---
## E2E Testing
### Cypress E2E Strategy
```typescript
// cypress/e2e/user-journey.cy.ts
describe('Complete User Journey', () => {
beforeEach(() => {
// Reset database state
cy.task('db:seed');
cy.visit('/');
});
it('should complete purchase flow', () => {
// Login
cy.getBySel('login-button').click();
cy.getBySel('email-input').type('test@example.com');
cy.getBySel('password-input').type('password123');
cy.getBySel('submit-button').click();
// Browse products
cy.url().should('include', '/products');
cy.getBySel('product-card').first().click();
// Add to cart
cy.getBySel('add-to-cart-button').click();
cy.getBySel('cart-badge').should('contain', '1');
// Checkout
cy.getBySel('cart-icon').click();
cy.getBySel('checkout-button').click();
// Fill shipping info
cy.getBySel('address-input').type('123 Main St');
cy.getBySel('city-input').type('New York');
cy.getBySel('zipcode-input').type('10001');
// Complete purchase
cy.getBySel('complete-order-button').click();
cy.url().should('include', '/order-confirmation');
cy.getBySel('success-message').should('be.visible');
});
});
```
### Playwright E2E Strategy
```typescript
// e2e/user-journey.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Complete User Journey', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should complete purchase flow', async ({ page }) => {
// Login
await page.click('[data-testid="login-button"]');
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="submit-button"]');
// Wait for navigation
await page.waitForURL('**/products');
// Browse products
await page.click('[data-testid="product-card"] >> nth=0');
// Add to cart
await page.click('[data-testid="add-to-cart-button"]');
await expect(page.locator('[data-testid="cart-badge"]')).toHaveText('1');
// Checkout
await page.click('[data-testid="cart-icon"]');
await page.click('[data-testid="checkout-button"]');
// Fill shipping info
await page.fill('[data-testid="address-input"]', '123 Main St');
await page.fill('[data-testid="city-input"]', 'New York');
await page.fill('[data-testid="zipcode-input"]', '10001');
// Complete purchase
await page.click('[data-testid="complete-order-button"]');
await expect(page).toHaveURL(/.*order-confirmation/);
await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
});
});
```
---
## Test Organization
### File Structure
```
src/app/
├── components/
│ ├── user-profile/
│ │ ├── user-profile.component.ts
│ │ ├── user-profile.component.spec.ts
│ │ ├── user-profile.component.html
│ │ └── user-profile.component.scss
│ └── shared/
│ └── button/
│ ├── button.component.ts
│ └── button.component.spec.ts
├── services/
│ ├── auth.service.ts
│ ├── auth.service.spec.ts
│ ├── user.service.ts
│ └── user.service.spec.ts
├── guards/
│ ├── auth.guard.ts
│ └── auth.guard.spec.ts
├── pipes/
│ ├── filter.pipe.ts
│ └── filter.pipe.spec.ts
└── testing/
├── mocks/
│ ├── mock-auth.service.ts
│ └── mock-user.service.ts
├── fixtures/
│ ├── user.fixture.ts
│ └── product.fixture.ts
└── helpers/
├── test-utils.ts
└── custom-matchers.ts
```
### Test Helper Functions
```typescript
// src/app/testing/helpers/test-utils.ts
/**
* Create a mock Observable that emits immediately
*/
export function createMockObservable<T>(data: T): Observable<T> {
return of(data);
}
/**
* Create a mock Observable that throws an error
*/
export function createMockError(error: any): Observable<never> {
return throwError(() => error);
}
/**
* Click a button and wait for async operations
*/
export async function clickButton(
fixture: ComponentFixture<any>,
selector: string
): Promise<void> {
const button = fixture.nativeElement.querySelector(selector);
button.click();
fixture.detectChanges();
await fixture.whenStable();
}
/**
* Set form values
*/
export function setFormValues(
form: FormGroup,
values: { [key: string]: any }
): void {
Object.keys(values).forEach(key => {
const control = form.get(key);
if (control) {
control.setValue(values[key]);
control.markAsTouched();
}
});
}
/**
* Create spy object with typed methods
*/
export function createSpyObj<T>(
baseName: string,
methodNames: (keyof T)[]
): jasmine.SpyObj<T> {
return jasmine.createSpyObj(baseName, methodNames as string[]);
}
```
### Test Fixtures
```typescript
// src/app/testing/fixtures/user.fixture.ts
export class UserFixtures {
static readonly VALID_USER = {
id: 1,
email: 'test@example.com',
name: 'Test User',
role: 'user'
};
static readonly ADMIN_USER = {
id: 2,
email: 'admin@example.com',
name: 'Admin User',
role: 'admin'
};
static readonly USER_LIST = [
UserFixtures.VALID_USER,
UserFixtures.ADMIN_USER,
{
id: 3,
email: 'user3@example.com',
name: 'User Three',
role: 'user'
}
];
static createUser(overrides: Partial<User> = {}): User {
return {
...UserFixtures.VALID_USER,
...overrides
};
}
}
```
---
## AAA Pattern
### Arrange-Act-Assert Structure
```typescript
describe('ShoppingCartService', () => {
it('should add item to cart', () => {
// ===== ARRANGE =====
// Set up test data and dependencies
const service = new ShoppingCartService();
const product = {
id: 1,
name: 'Test Product',
price: 99.99
};
// ===== ACT =====
// Execute the behavior being tested
service.addToCart(product);
// ===== ASSERT =====
// Verify the expected outcome
expect(service.getCartItems().length).toBe(1);
expect(service.getCartItems()[0]).toEqual(product);
expect(service.getTotal()).toBe(99.99);
});
});
```
### Why AAA Pattern?
**Readability**: Clear test structure
**Maintainability**: Easy to update
**Debugging**: Quickly identify failures
**Consistency**: Uniform test style
### Common Variations
```typescript
// Given-When-Then (BDD style)
it('should add item to cart', () => {
// GIVEN: A shopping cart and a product
const service = new ShoppingCartService();
const product = { id: 1, name: 'Product', price: 99.99 };
// WHEN: Adding the product to cart
service.addToCart(product);
// THEN: Cart should contain the product
expect(service.getCartItems()).toContain(product);
});
```
---
## Test Doubles
### Types of Test Doubles
1. **Dummy**: Passed but never used
2. **Stub**: Provides preset responses
3. **Spy**: Records how it was called
4. **Mock**: Pre-programmed with expectations
5. **Fake**: Working implementation (simplified)
### Jasmine Spies
```typescript
// Creating spies
const spy = jasmine.createSpy('myFunction');
const spyObj = jasmine.createSpyObj('MyService', ['method1', 'method2']);
// Spy on existing method
spyOn(service, 'getData').and.returnValue(of(mockData));
// Spy configurations
spy.and.returnValue(42);
spy.and.returnValues(1, 2, 3);
spy.and.callFake(() => 'custom logic');
spy.and.callThrough(); // Call actual method
spy.and.throwError('error message');
// Assertions
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledWith('arg1', 'arg2');
expect(spy).toHaveBeenCalledBefore(anotherSpy);
```
### Mock HTTP Responses
```typescript
it('should handle successful API call', () => {
service.getUsers().subscribe(users => {
expect(users.length).toBe(2);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush([
{ id: 1, name: 'User 1' },
{ id: 2, name: 'User 2' }
]);
});
it('should handle API errors', () => {
service.getUsers().subscribe({
error: (error) => {
expect(error.status).toBe(404);
}
});
const req = httpMock.expectOne('/api/users');
req.flush('Not Found', { status: 404, statusText: 'Not Found' });
});
```
---
## Common Anti-Patterns
### ❌ Testing Implementation Details
```typescript
// BAD: Testing private methods
it('should call private method', () => {
spyOn(component as any, 'privateMethod');
component.publicMethod();
expect((component as any).privateMethod).toHaveBeenCalled();
});
// GOOD: Test public behavior
it('should update user list', () => {
component.refreshUsers();
expect(component.users.length).toBeGreaterThan(0);
});
```
### ❌ Flaky Tests
```typescript
// BAD: Time-dependent tests
it('should update after delay', (done) => {
service.delayedUpdate();
setTimeout(() => {
expect(service.value).toBe(42);
done();
}, 100); // Magic number!
});
// GOOD: Use fakeAsync
it('should update after delay', fakeAsync(() => {
service.delayedUpdate();
tick(1000);
expect(service.value).toBe(42);
}));
```
### ❌ Test Interdependence
```typescript
// BAD: Tests depend on each other
describe('UserService', () => {
let userId: number;
it('should create user', () => {
userId = service.createUser({ name: 'Test' });
expect(userId).toBeDefined();
});
it('should get user', () => {
const user = service.getUser(userId); // Depends on previous test!
expect(user.name).toBe('Test');
});
});
// GOOD: Independent tests
describe('UserService', () => {
beforeEach(() => {
// Reset state before each test
service.clear();
});
it('should create user', () => {
const userId = service.createUser({ name: 'Test' });
expect(userId).toBeDefined();
});
it('should get user', () => {
const userId = service.createUser({ name: 'Test' });
const user = service.getUser(userId);
expect(user.name).toBe('Test');
});
});
```
### ❌ Over-Mocking
```typescript
// BAD: Mocking everything
it('should calculate total', () => {
spyOn(Math, 'round').and.returnValue(100);
spyOn(Math, 'floor').and.returnValue(99);
// Too many mocks!
});
// GOOD: Only mock external dependencies
it('should calculate total', () => {
const cart = new ShoppingCart();
cart.addItem({ price: 10, quantity: 3 });
expect(cart.getTotal()).toBe(30);
});
```
---
## Performance Optimization
### Run Tests in Parallel
```json
// karma.conf.js
module.exports = function(config) {
config.set({
browsers: ['ChromeHeadless'],
concurrency: 5, // Run 5 instances in parallel
browserNoActivityTimeout: 60000
});
};
```
### Use Shallow Testing When Possible
```typescript
// Fast: Shallow test
describe('Component (Shallow)', () => {
it('should calculate total', () => {
const component = new PriceCalculatorComponent();
component.price = 100;
component.quantity = 2;
expect(component.total).toBe(200);
});
});
// Slow: Full TestBed
describe('Component (Deep)', () => {
let fixture: ComponentFixture<PriceCalculatorComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ PriceCalculatorComponent ],
imports: [ CommonModule, FormsModule ]
}).compileComponents();
fixture = TestBed.createComponent(PriceCalculatorComponent);
});
it('should calculate total', () => {
// Much slower setup
});
});
```
### Skip Heavy Tests in Watch Mode
```typescript
// Use fdescribe/fit for focused testing during development
fdescribe('UserComponent', () => {
fit('should create', () => {
expect(component).toBeTruthy();
});
});
// Or use xdescribe/xit to skip tests
xdescribe('SlowE2ETests', () => {
// These tests won't run
});
```
---
## CI/CD Integration
### GitHub Actions
```yaml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:ci
- name: Generate coverage
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
- name: Run E2E tests
run: npm run e2e:ci
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: |
coverage/
cypress/screenshots/
cypress/videos/
```
### Package.json Scripts
```json
{
"scripts": {
"test": "ng test",
"test:ci": "ng test --watch=false --browsers=ChromeHeadless --code-coverage",
"test:coverage": "ng test --code-coverage --watch=false",
"test:watch": "ng test --watch=true",
"e2e": "cypress open",
"e2e:ci": "cypress run --browser chrome"
}
}
```
---
## Best Practices Summary
**DO**:
- Follow AAA pattern
- Test behavior, not implementation
- Keep tests isolated and independent
- Use descriptive test names
- Mock external dependencies
- Aim for 80% code coverage
- Run tests before committing
- Use CI/CD for automated testing
**DON'T**:
- Test framework internals
- Create dependent tests
- Use magic numbers or strings
- Test private methods
- Ignore flaky tests
- Skip edge cases
- Over-mock everything
---
## Quick Reference
### Test Types
| Type | Purpose | Speed | Coverage |
|------|---------|-------|----------|
| Unit | Test isolated logic | ⚡⚡⚡ | 80% |
| Integration | Test component + service | ⚡⚡ | 15% |
| E2E | Test user workflows | ⚡ | 5% |
### Common Matchers
```typescript
expect(value).toBe(42);
expect(value).toEqual({ id: 1 });
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(array).toContain(item);
expect(fn).toThrow();
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(arg);
```
### Async Testing
```typescript
// Using done()
it('async test', (done) => {
service.getData().subscribe(data => {
expect(data).toBeTruthy();
done();
});
});
// Using fakeAsync
it('async test', fakeAsync(() => {
service.delayedAction();
tick(1000);
expect(service.value).toBe(42);
}));
// Using async/await
it('async test', async () => {
const data = await service.getData().toPromise();
expect(data).toBeTruthy();
});
```
---
*Master these strategies to build bulletproof Angular applications! 🛡️*