commit 51724191c4e7cbf64cc5414c4259b58504d72dd8 Author: Zhongwei Li Date: Sat Nov 29 18:25:05 2025 +0800 Initial commit diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..8f70d52 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "name": "angular-testing", + "description": "Comprehensive testing strategies with Jasmine, Jest, Cypress, and Playwright", + "version": "1.0.0", + "author": { + "name": "Ihsan - Full-Stack Developer & AI Strategist", + "url": "https://github.com/EhssanAtassi" + }, + "skills": [ + "./skills/testing-strategies/SKILL.md", + "./skills/mocking-patterns/SKILL.md" + ], + "agents": [ + "./agents/angular-tester.md" + ], + "commands": [ + "./commands/generate-tests.md", + "./commands/run-e2e.md" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f534309 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# angular-testing + +Comprehensive testing strategies with Jasmine, Jest, Cypress, and Playwright diff --git a/agents/angular-tester.md b/agents/angular-tester.md new file mode 100644 index 0000000..45062e9 --- /dev/null +++ b/agents/angular-tester.md @@ -0,0 +1,271 @@ +# Angular Testing Specialist + +You are an expert Angular testing engineer specializing in comprehensive testing strategies for Angular applications. + +## Your Expertise + +- **Unit Testing**: Jasmine/Karma, Jest configuration and best practices +- **Component Testing**: TestBed, fixtures, change detection, and mocking +- **Integration Testing**: Service integration, routing, guards, and interceptors +- **E2E Testing**: Cypress, Playwright, and comprehensive user flow testing +- **Test Automation**: CI/CD integration, coverage reporting, and test optimization +- **Testing Patterns**: AAA pattern, test doubles, and testing anti-patterns +- **Performance Testing**: Test execution optimization and debugging + +## Core Responsibilities + +1. **Generate comprehensive test suites** for components, services, and modules +2. **Design testing strategies** that balance coverage, speed, and maintainability +3. **Implement E2E test scenarios** for critical user journeys +4. **Configure testing frameworks** (Jasmine, Jest, Cypress, Playwright) +5. **Optimize test performance** and reduce flakiness +6. **Integrate testing into CI/CD** pipelines +7. **Provide testing best practices** and anti-pattern guidance + +## Testing Philosophy + +- **Test behavior, not implementation** - Focus on user-facing functionality +- **Maintainable tests** - Write tests that are easy to update +- **Fast feedback loops** - Optimize for quick test execution +- **Comprehensive coverage** - Unit, integration, and E2E balance +- **Reliable tests** - Eliminate flakiness and race conditions + +## Available Commands + +- `/angular-testing:generate-tests` - Generate unit and integration tests +- `/angular-testing:run-e2e` - Set up and run E2E tests + +## Available Skills + +Read these for deep knowledge: +- `testing-strategies` - Comprehensive testing approaches and patterns +- `mocking-patterns` - Service mocking, spies, and test doubles + +## Communication Style + +- Provide **working, complete test files** with proper imports +- Explain **what is being tested** and why +- Include **setup and teardown** patterns +- Add **comments** for complex test scenarios +- Suggest **test organization** and file structure +- Highlight **common pitfalls** and how to avoid them + +## Code Quality Standards + +1. **Follow AAA Pattern**: Arrange, Act, Assert +2. **One assertion per test**: Clear, focused tests +3. **Descriptive test names**: Use "should..." convention +4. **Proper cleanup**: AfterEach hooks for teardown +5. **No hardcoded values**: Use constants and fixtures +6. **Test isolation**: Each test runs independently +7. **Mock external dependencies**: Control test environment + +## Example Interaction + +**User**: "Create tests for my login component" + +**You**: +```typescript +// login.component.spec.ts +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { LoginComponent } from './login.component'; +import { AuthService } from '../services/auth.service'; +import { Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + let authService: jasmine.SpyObj; + let router: jasmine.SpyObj; + + beforeEach(async () => { + const authServiceSpy = jasmine.createSpyObj('AuthService', ['login']); + const routerSpy = jasmine.createSpyObj('Router', ['navigate']); + + await TestBed.configureTestingModule({ + declarations: [ LoginComponent ], + imports: [ ReactiveFormsModule ], + providers: [ + { provide: AuthService, useValue: authServiceSpy }, + { provide: Router, useValue: routerSpy } + ] + }).compileComponents(); + + authService = TestBed.inject(AuthService) as jasmine.SpyObj; + router = TestBed.inject(Router) as jasmine.SpyObj; + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('Form Validation', () => { + it('should create form with email and password controls', () => { + expect(component.loginForm.contains('email')).toBeTruthy(); + expect(component.loginForm.contains('password')).toBeTruthy(); + }); + + it('should mark email as invalid when empty', () => { + const email = component.loginForm.get('email'); + expect(email?.valid).toBeFalsy(); + expect(email?.hasError('required')).toBeTruthy(); + }); + + it('should mark email as invalid when format is wrong', () => { + const email = component.loginForm.get('email'); + email?.setValue('invalid-email'); + expect(email?.hasError('email')).toBeTruthy(); + }); + + it('should mark password as invalid when empty', () => { + const password = component.loginForm.get('password'); + expect(password?.valid).toBeFalsy(); + expect(password?.hasError('required')).toBeTruthy(); + }); + + it('should mark form as valid when all fields are correct', () => { + component.loginForm.setValue({ + email: 'test@example.com', + password: 'password123' + }); + expect(component.loginForm.valid).toBeTruthy(); + }); + }); + + describe('Login Functionality', () => { + it('should not call authService when form is invalid', () => { + component.onSubmit(); + expect(authService.login).not.toHaveBeenCalled(); + }); + + it('should call authService.login with correct credentials', () => { + const credentials = { + email: 'test@example.com', + password: 'password123' + }; + + component.loginForm.setValue(credentials); + authService.login.and.returnValue(of({ token: 'fake-token' })); + + component.onSubmit(); + + expect(authService.login).toHaveBeenCalledWith( + credentials.email, + credentials.password + ); + }); + + it('should navigate to dashboard on successful login', () => { + component.loginForm.setValue({ + email: 'test@example.com', + password: 'password123' + }); + + authService.login.and.returnValue(of({ token: 'fake-token' })); + + component.onSubmit(); + + expect(router.navigate).toHaveBeenCalledWith(['/dashboard']); + }); + + it('should display error message on failed login', () => { + component.loginForm.setValue({ + email: 'test@example.com', + password: 'wrong-password' + }); + + const errorResponse = { error: { message: 'Invalid credentials' } }; + authService.login.and.returnValue(throwError(() => errorResponse)); + + component.onSubmit(); + + expect(component.errorMessage).toBe('Invalid credentials'); + }); + + it('should disable submit button while login is in progress', () => { + component.loginForm.setValue({ + email: 'test@example.com', + password: 'password123' + }); + + authService.login.and.returnValue(of({ token: 'fake-token' })); + + component.onSubmit(); + + expect(component.isLoading).toBeTruthy(); + }); + }); + + describe('UI Interaction', () => { + it('should display email error message when touched and invalid', () => { + const email = component.loginForm.get('email'); + email?.markAsTouched(); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + const errorElement = compiled.querySelector('.email-error'); + + expect(errorElement).toBeTruthy(); + }); + + it('should disable submit button when form is invalid', () => { + const compiled = fixture.nativeElement; + const submitButton = compiled.querySelector('button[type="submit"]'); + + expect(submitButton.disabled).toBeTruthy(); + }); + + it('should enable submit button when form is valid', () => { + component.loginForm.setValue({ + email: 'test@example.com', + password: 'password123' + }); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + const submitButton = compiled.querySelector('button[type="submit"]'); + + expect(submitButton.disabled).toBeFalsy(); + }); + }); +}); +``` + +**Test Coverage**: ✅ Form validation, ✅ Service integration, ✅ Navigation, ✅ Error handling, ✅ UI states + +## When to Use Which Test Type + +**Unit Tests** (80% of tests): +- Pure functions and business logic +- Component methods in isolation +- Service methods +- Pipes and directives + +**Integration Tests** (15% of tests): +- Component + Service interactions +- Routing with guards +- Form + Validation +- HTTP interceptors + +**E2E Tests** (5% of tests): +- Critical user journeys +- Authentication flows +- Checkout processes +- Data persistence + +## Always Remember + +- Tests are documentation - make them readable +- Fast tests = happy developers +- Flaky tests are worse than no tests +- Mock external dependencies +- Test edge cases and error scenarios +- Keep tests close to the code they test + +--- + +*Ready to make your Angular app bulletproof! 🛡️* diff --git a/commands/generate-tests.md b/commands/generate-tests.md new file mode 100644 index 0000000..729c2fd --- /dev/null +++ b/commands/generate-tests.md @@ -0,0 +1,507 @@ +# Generate Tests Command + +Generate comprehensive unit and integration tests for Angular components, services, and modules. + +## Command Usage + +```bash +/angular-testing:generate-tests [component|service|module|pipe|directive] +``` + +## Natural Language Examples + +- "Generate tests for my user profile component" +- "Create unit tests for the authentication service" +- "Write tests for the admin guard" +- "Generate tests for the currency pipe" + +## What This Command Does + +1. **Analyzes the target file** - Understands component/service structure +2. **Creates comprehensive test suite** - Following AAA pattern +3. **Includes all test scenarios** - Happy path, edge cases, errors +4. **Mocks dependencies** - Proper service and HTTP mocking +5. **Adds helpful comments** - Explains complex test logic +6. **Follows best practices** - TestBed configuration, spy objects + +## Test Generation Patterns + +### Component Tests Include + +```typescript +describe('ComponentName', () => { + // ✅ Component creation and initialization + // ✅ Input property testing + // ✅ Output event testing + // ✅ Method behavior testing + // ✅ Template rendering + // ✅ User interaction + // ✅ Async operations + // ✅ Error handling +}); +``` + +### Service Tests Include + +```typescript +describe('ServiceName', () => { + // ✅ Service creation + // ✅ Method functionality + // ✅ HTTP requests (mocked) + // ✅ Observable streams + // ✅ Error handling + // ✅ State management + // ✅ Cache behavior +}); +``` + +## Example Output + +**Input**: Generate tests for LoginComponent + +**Output**: +```typescript +// login.component.spec.ts +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { LoginComponent } from './login.component'; +import { AuthService } from '../services/auth.service'; +import { Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + let authService: jasmine.SpyObj; + let router: jasmine.SpyObj; + let compiled: HTMLElement; + + beforeEach(async () => { + // Create spy objects for dependencies + const authServiceSpy = jasmine.createSpyObj('AuthService', ['login', 'logout']); + const routerSpy = jasmine.createSpyObj('Router', ['navigate']); + + await TestBed.configureTestingModule({ + declarations: [ LoginComponent ], + imports: [ ReactiveFormsModule ], + providers: [ + { provide: AuthService, useValue: authServiceSpy }, + { provide: Router, useValue: routerSpy } + ] + }).compileComponents(); + + authService = TestBed.inject(AuthService) as jasmine.SpyObj; + router = TestBed.inject(Router) as jasmine.SpyObj; + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + compiled = fixture.nativeElement; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + // === COMPONENT CREATION === + describe('Component Creation', () => { + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize login form with empty values', () => { + expect(component.loginForm.get('email')?.value).toBe(''); + expect(component.loginForm.get('password')?.value).toBe(''); + }); + + it('should have isLoading set to false initially', () => { + expect(component.isLoading).toBeFalsy(); + }); + + it('should have errorMessage set to null initially', () => { + expect(component.errorMessage).toBeNull(); + }); + }); + + // === FORM VALIDATION === + describe('Form Validation', () => { + it('should have required validator on email field', () => { + const email = component.loginForm.get('email'); + email?.setValue(''); + expect(email?.hasError('required')).toBeTruthy(); + }); + + it('should have email validator on email field', () => { + const email = component.loginForm.get('email'); + email?.setValue('invalid-email'); + expect(email?.hasError('email')).toBeTruthy(); + }); + + it('should accept valid email format', () => { + const email = component.loginForm.get('email'); + email?.setValue('test@example.com'); + expect(email?.valid).toBeTruthy(); + }); + + it('should have required validator on password field', () => { + const password = component.loginForm.get('password'); + password?.setValue(''); + expect(password?.hasError('required')).toBeTruthy(); + }); + + it('should have minLength validator on password field', () => { + const password = component.loginForm.get('password'); + password?.setValue('123'); + expect(password?.hasError('minlength')).toBeTruthy(); + }); + + it('should mark form as invalid when fields are empty', () => { + expect(component.loginForm.valid).toBeFalsy(); + }); + + it('should mark form as valid when all fields are correct', () => { + component.loginForm.setValue({ + email: 'test@example.com', + password: 'password123' + }); + expect(component.loginForm.valid).toBeTruthy(); + }); + }); + + // === LOGIN FUNCTIONALITY === + describe('Login Functionality', () => { + const validCredentials = { + email: 'test@example.com', + password: 'password123' + }; + + beforeEach(() => { + component.loginForm.setValue(validCredentials); + }); + + it('should not submit when form is invalid', () => { + component.loginForm.setValue({ email: '', password: '' }); + component.onSubmit(); + expect(authService.login).not.toHaveBeenCalled(); + }); + + it('should call authService.login with form values', () => { + authService.login.and.returnValue(of({ + token: 'fake-jwt-token', + user: { id: 1, email: 'test@example.com' } + })); + + component.onSubmit(); + + expect(authService.login).toHaveBeenCalledWith( + validCredentials.email, + validCredentials.password + ); + }); + + it('should set isLoading to true during login', () => { + authService.login.and.returnValue(of({ token: 'fake-token' })); + + component.onSubmit(); + + expect(component.isLoading).toBeTruthy(); + }); + + it('should navigate to dashboard on successful login', (done) => { + authService.login.and.returnValue(of({ token: 'fake-token' })); + + component.onSubmit(); + + setTimeout(() => { + expect(router.navigate).toHaveBeenCalledWith(['/dashboard']); + expect(component.isLoading).toBeFalsy(); + done(); + }, 100); + }); + + it('should display error message on login failure', (done) => { + const errorResponse = { + error: { message: 'Invalid credentials' } + }; + authService.login.and.returnValue( + throwError(() => errorResponse) + ); + + component.onSubmit(); + + setTimeout(() => { + expect(component.errorMessage).toBe('Invalid credentials'); + expect(component.isLoading).toBeFalsy(); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }, 100); + }); + + it('should clear error message on new submission', () => { + component.errorMessage = 'Previous error'; + authService.login.and.returnValue(of({ token: 'fake-token' })); + + component.onSubmit(); + + expect(component.errorMessage).toBeNull(); + }); + }); + + // === UI INTERACTION === + describe('UI Interaction', () => { + it('should render email input field', () => { + const emailInput = compiled.querySelector('input[type="email"]'); + expect(emailInput).toBeTruthy(); + }); + + it('should render password input field', () => { + const passwordInput = compiled.querySelector('input[type="password"]'); + expect(passwordInput).toBeTruthy(); + }); + + it('should render submit button', () => { + const submitButton = compiled.querySelector('button[type="submit"]'); + expect(submitButton).toBeTruthy(); + }); + + it('should disable submit button when form is invalid', () => { + const submitButton = compiled.querySelector('button[type="submit"]') as HTMLButtonElement; + expect(submitButton.disabled).toBeTruthy(); + }); + + it('should enable submit button when form is valid', () => { + component.loginForm.setValue({ + email: 'test@example.com', + password: 'password123' + }); + fixture.detectChanges(); + + const submitButton = compiled.querySelector('button[type="submit"]') as HTMLButtonElement; + expect(submitButton.disabled).toBeFalsy(); + }); + + it('should show loading spinner when isLoading is true', () => { + component.isLoading = true; + fixture.detectChanges(); + + const spinner = compiled.querySelector('.loading-spinner'); + expect(spinner).toBeTruthy(); + }); + + it('should display error message when present', () => { + component.errorMessage = 'Login failed'; + fixture.detectChanges(); + + const errorElement = compiled.querySelector('.error-message'); + expect(errorElement?.textContent).toContain('Login failed'); + }); + + it('should update email value on input', () => { + const emailInput = compiled.querySelector('input[type="email"]') as HTMLInputElement; + emailInput.value = 'new@example.com'; + emailInput.dispatchEvent(new Event('input')); + + expect(component.loginForm.get('email')?.value).toBe('new@example.com'); + }); + + it('should show validation error when email is touched and invalid', () => { + const email = component.loginForm.get('email'); + email?.markAsTouched(); + email?.setValue(''); + fixture.detectChanges(); + + const errorElement = compiled.querySelector('.email-error'); + expect(errorElement).toBeTruthy(); + }); + + it('should trigger onSubmit when form is submitted', () => { + spyOn(component, 'onSubmit'); + + component.loginForm.setValue({ + email: 'test@example.com', + password: 'password123' + }); + + const form = compiled.querySelector('form') as HTMLFormElement; + form.dispatchEvent(new Event('submit')); + + expect(component.onSubmit).toHaveBeenCalled(); + }); + }); + + // === EDGE CASES === + describe('Edge Cases', () => { + it('should handle network error gracefully', (done) => { + component.loginForm.setValue({ + email: 'test@example.com', + password: 'password123' + }); + + authService.login.and.returnValue( + throwError(() => new Error('Network error')) + ); + + component.onSubmit(); + + setTimeout(() => { + expect(component.errorMessage).toBe('Network error occurred'); + expect(component.isLoading).toBeFalsy(); + done(); + }, 100); + }); + + it('should prevent multiple simultaneous login requests', () => { + component.loginForm.setValue({ + email: 'test@example.com', + password: 'password123' + }); + + authService.login.and.returnValue(of({ token: 'fake-token' })); + + component.onSubmit(); + component.onSubmit(); // Try to submit again + + expect(authService.login).toHaveBeenCalledTimes(1); + }); + + it('should trim whitespace from email', () => { + component.loginForm.get('email')?.setValue(' test@example.com '); + authService.login.and.returnValue(of({ token: 'fake-token' })); + + component.onSubmit(); + + expect(authService.login).toHaveBeenCalledWith( + 'test@example.com', + jasmine.any(String) + ); + }); + }); +}); +``` + +**Test Coverage Report**: +``` +✅ Component Creation (4 tests) +✅ Form Validation (7 tests) +✅ Login Functionality (6 tests) +✅ UI Interaction (10 tests) +✅ Edge Cases (3 tests) +─────────────────────────── +Total: 30 tests +Coverage: ~95% +``` + +## Configuration Tips + +### Jasmine/Karma Setup + +```typescript +// 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-coverage'), + require('@angular-devkit/build-angular/plugins/karma') + ], + coverageReporter: { + dir: require('path').join(__dirname, './coverage'), + subdir: '.', + reporters: [ + { type: 'html' }, + { type: 'text-summary' }, + { type: 'lcovonly' } + ] + }, + reporters: ['progress', 'coverage'], + browsers: ['ChromeHeadless'], + singleRun: true + }); +}; +``` + +### Jest Setup (Alternative) + +```typescript +// jest.config.js +module.exports = { + preset: 'jest-preset-angular', + setupFilesAfterEnv: ['/setup-jest.ts'], + testPathIgnorePatterns: ['/node_modules/', '/dist/'], + coverageDirectory: 'coverage', + collectCoverageFrom: [ + 'src/app/**/*.ts', + '!src/app/**/*.spec.ts', + '!src/app/**/*.module.ts' + ], + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80 + } + } +}; +``` + +## Running Tests + +```bash +# Run all tests +ng test + +# Run with coverage +ng test --code-coverage + +# Run specific file +ng test --include='**/login.component.spec.ts' + +# Run in headless mode +ng test --browsers=ChromeHeadless --watch=false + +# Run with Jest +npm run test:jest +``` + +## Best Practices Applied + +1. **AAA Pattern**: Arrange → Act → Assert structure +2. **Descriptive Names**: Clear "should..." test descriptions +3. **Isolated Tests**: Each test independent and focused +4. **Proper Mocking**: Spy objects for all dependencies +5. **Complete Coverage**: Happy path, errors, edge cases +6. **Cleanup**: afterEach for proper teardown +7. **Async Handling**: Proper async/await and done() + +## Usage + +```bash +/angular-testing:generate-tests + +# Examples +"Generate tests for UserProfileComponent" +"Create tests for DataService" +"Write tests for AuthGuard" +``` + +## Output Includes + +1. **Complete test file** - Ready to run +2. **All imports** - Proper dependencies +3. **TestBed configuration** - Correct module setup +4. **Spy objects** - For all dependencies +5. **Comprehensive scenarios** - All test cases +6. **Comments** - Explaining complex logic +7. **Running instructions** - How to execute tests + +--- + +*Comprehensive testing made easy! 🧪* diff --git a/commands/run-e2e.md b/commands/run-e2e.md new file mode 100644 index 0000000..17f4412 --- /dev/null +++ b/commands/run-e2e.md @@ -0,0 +1,377 @@ +# Run E2E Tests Command + +Set up and execute end-to-end tests using Cypress or Playwright for Angular applications. + +## Command Usage + +```bash +/angular-testing:run-e2e [cypress|playwright] [setup|run|debug] +``` + +## Natural Language Examples + +- "Set up Cypress for E2E testing" +- "Create E2E tests for login flow" +- "Run Playwright tests in headless mode" +- "Debug failing E2E tests" + +## What This Command Does + +1. **Framework Setup** - Install and configure Cypress/Playwright +2. **Test Generation** - Create E2E test files for user flows +3. **Custom Commands** - Reusable helpers and utilities +4. **CI/CD Integration** - GitHub Actions/GitLab CI configuration +5. **Debugging** - Tools and strategies for flaky tests + +--- + +## Cypress Setup + +### Installation + +```bash +npm install --save-dev cypress @cypress/schematic +ng add @cypress/schematic +``` + +### Configuration + +```typescript +// cypress.config.ts +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + baseUrl: 'http://localhost:4200', + supportFile: 'cypress/support/e2e.ts', + specPattern: 'cypress/e2e/**/*.cy.ts', + viewportWidth: 1280, + viewportHeight: 720, + video: true, + screenshotOnRunFailure: true, + retries: { + runMode: 2, + openMode: 0 + }, + env: { + apiUrl: 'http://localhost:3000/api' + } + } +}); +``` + +### Custom Commands + +```typescript +// cypress/support/commands.ts +declare global { + namespace Cypress { + interface Chainable { + login(email: string, password: string): Chainable; + logout(): Chainable; + getBySel(selector: string): Chainable>; + checkAccessibility(): Chainable; + } + } +} + +// Login command +Cypress.Commands.add('login', (email: string, password: string) => { + cy.session([email, password], () => { + cy.visit('/login'); + cy.getBySel('email-input').type(email); + cy.getBySel('password-input').type(password); + cy.getBySel('login-button').click(); + cy.url().should('include', '/dashboard'); + cy.window().its('localStorage.token').should('exist'); + }); +}); + +// Logout command +Cypress.Commands.add('logout', () => { + cy.clearLocalStorage(); + cy.clearCookies(); +}); + +// Get by data-cy attribute +Cypress.Commands.add('getBySel', (selector: string) => { + return cy.get(`[data-cy="${selector}"]`); +}); + +// Accessibility check +Cypress.Commands.add('checkAccessibility', () => { + cy.injectAxe(); + cy.checkA11y(); +}); +``` + +### Example E2E Test + +```typescript +// cypress/e2e/login.cy.ts +describe('Login Flow', () => { + beforeEach(() => { + cy.visit('/login'); + }); + + it('should display login form', () => { + cy.getBySel('email-input').should('be.visible'); + cy.getBySel('password-input').should('be.visible'); + cy.getBySel('login-button').should('be.visible'); + }); + + it('should show validation errors for empty fields', () => { + cy.getBySel('login-button').click(); + cy.getBySel('email-error').should('contain', 'Email is required'); + cy.getBySel('password-error').should('contain', 'Password is required'); + }); + + it('should show error for invalid email format', () => { + cy.getBySel('email-input').type('invalid-email'); + cy.getBySel('password-input').type('password123'); + cy.getBySel('login-button').click(); + cy.getBySel('email-error').should('contain', 'Invalid email format'); + }); + + it('should successfully login with valid credentials', () => { + cy.intercept('POST', '/api/auth/login', { + statusCode: 200, + body: { + token: 'fake-jwt-token', + user: { id: 1, email: 'test@example.com' } + } + }).as('loginRequest'); + + cy.getBySel('email-input').type('test@example.com'); + cy.getBySel('password-input').type('password123'); + cy.getBySel('login-button').click(); + + cy.wait('@loginRequest'); + cy.url().should('include', '/dashboard'); + cy.window().its('localStorage.token').should('exist'); + }); + + it('should show error message for invalid credentials', () => { + cy.intercept('POST', '/api/auth/login', { + statusCode: 401, + body: { message: 'Invalid credentials' } + }).as('loginRequest'); + + cy.getBySel('email-input').type('test@example.com'); + cy.getBySel('password-input').type('wrong-password'); + cy.getBySel('login-button').click(); + + cy.wait('@loginRequest'); + cy.getBySel('error-message').should('contain', 'Invalid credentials'); + cy.url().should('include', '/login'); + }); + + it('should toggle password visibility', () => { + cy.getBySel('password-input').should('have.attr', 'type', 'password'); + cy.getBySel('toggle-password').click(); + cy.getBySel('password-input').should('have.attr', 'type', 'text'); + cy.getBySel('toggle-password').click(); + cy.getBySel('password-input').should('have.attr', 'type', 'password'); + }); + + it('should remember me functionality', () => { + cy.getBySel('remember-me-checkbox').check(); + cy.login('test@example.com', 'password123'); + + cy.reload(); + cy.url().should('include', '/dashboard'); + }); +}); +``` + +### Running Cypress Tests + +```bash +# Open Cypress Test Runner +npx cypress open + +# Run all tests headless +npx cypress run + +# Run specific test +npx cypress run --spec "cypress/e2e/login.cy.ts" + +# Run with specific browser +npx cypress run --browser chrome +``` + +--- + +## Playwright Setup + +### Installation + +```bash +npm install --save-dev @playwright/test +npx playwright install +``` + +### Configuration + +```typescript +// playwright.config.ts +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:4200', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure' + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] } + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] } + } + ], + webServer: { + command: 'npm start', + url: 'http://localhost:4200', + reuseExistingServer: !process.env.CI + } +}); +``` + +### Example Playwright Test + +```typescript +// e2e/login.spec.ts +import { test, expect } from '@playwright/test'; + +test.describe('Login Flow', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/login'); + }); + + test('should display login form', async ({ page }) => { + await expect(page.getByTestId('email-input')).toBeVisible(); + await expect(page.getByTestId('password-input')).toBeVisible(); + await expect(page.getByTestId('login-button')).toBeVisible(); + }); + + test('should login successfully', async ({ page }) => { + await page.route('**/api/auth/login', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + token: 'fake-jwt-token', + user: { id: 1, email: 'test@example.com' } + }) + }); + }); + + await page.fill('[data-testid="email-input"]', 'test@example.com'); + await page.fill('[data-testid="password-input"]', 'password123'); + await page.click('[data-testid="login-button"]'); + + await expect(page).toHaveURL(/.*dashboard/); + + const token = await page.evaluate(() => localStorage.getItem('token')); + expect(token).toBeTruthy(); + }); +}); +``` + +### Running Playwright Tests + +```bash +# Run all tests +npx playwright test + +# Run with UI +npx playwright test --ui + +# Debug mode +npx playwright test --debug + +# Generate report +npx playwright show-report +``` + +--- + +## CI/CD Integration + +### GitHub Actions + +```yaml +# .github/workflows/e2e.yml +name: E2E Tests + +on: [push, pull_request] + +jobs: + cypress: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Install dependencies + run: npm ci + + - name: Start Angular dev server + run: npm start & + + - name: Wait for server + run: npx wait-on http://localhost:4200 + + - name: Run Cypress tests + run: npm run cypress:run + + - name: Upload screenshots + if: failure() + uses: actions/upload-artifact@v3 + with: + name: cypress-screenshots + path: cypress/screenshots +``` + +## Best Practices + +1. **Use data-cy/data-testid attributes** - For stable selectors +2. **Avoid waits** - Use Cypress auto-retry +3. **Test user flows** - Not individual features +4. **Mock external APIs** - For consistency +5. **Keep tests independent** - No shared state +6. **Use fixtures** - For test data +7. **Clean up after tests** - Reset state + +## Usage + +```bash +/angular-testing:run-e2e + +# Examples +"Set up Cypress for testing login flow" +"Create E2E tests for product CRUD operations" +"Run E2E tests in CI/CD pipeline" +``` + +--- + +*Comprehensive E2E testing made easy! 🎭* diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..eb7d6c9 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,61 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:EhssanAtassi/angular-marketplace-developer:plugins/angular-testing", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "ca8ac5cebc93a36c1d98fa06385946424758d740", + "treeHash": "6165c7bcf054cb89e9a3f469d0351f49a8890dd024afe3044a8d6d8b7b1c273c", + "generatedAt": "2025-11-28T10:10:28.277292Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "angular-testing", + "description": "Comprehensive testing strategies with Jasmine, Jest, Cypress, and Playwright", + "version": "1.0.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "00cf792c7fc5e3bb2c0eb026bd574a7ab7cf732774fbb31750a88533374220a1" + }, + { + "path": "agents/angular-tester.md", + "sha256": "1cf4c37819ab7e1c9cf127c6d155a795cc8a5390b2f1d4d2c933f5e36e47c44f" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "ef1ffa49e0320d2eefb6b422e746c545af12c44e9371a33a424448e93457fa51" + }, + { + "path": "commands/run-e2e.md", + "sha256": "b701857e152e8e59a94e62ef99dfcff7d52bf4d85a7447389905c1a180f6e6b6" + }, + { + "path": "commands/generate-tests.md", + "sha256": "3c731f0ae6f6bac48853ab5f0a58213158a30855dc418b2130e9352e90c1a0ad" + }, + { + "path": "skills/mocking-patterns/SKILL.md", + "sha256": "d2ac058c7b0aa234522373212af418c78406ac47a6be4e307a38055f3a624a9c" + }, + { + "path": "skills/testing-strategies/SKILL.md", + "sha256": "c7e2e5c426f42e657bb5ea2cdab563f24ab04b89e5e9de7612928eb0514e70aa" + } + ], + "dirSha256": "6165c7bcf054cb89e9a3f469d0351f49a8890dd024afe3044a8d6d8b7b1c273c" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/mocking-patterns/SKILL.md b/skills/mocking-patterns/SKILL.md new file mode 100644 index 0000000..ed86cc7 --- /dev/null +++ b/skills/mocking-patterns/SKILL.md @@ -0,0 +1,1174 @@ +# Angular Mocking Patterns + +Comprehensive guide to mocking patterns, test doubles, spies, and HTTP testing in Angular applications. + +## Table of Contents + +1. [Test Doubles Overview](#test-doubles-overview) +2. [Jasmine Spies](#jasmine-spies) +3. [Service Mocking](#service-mocking) +4. [HTTP Mocking](#http-mocking) +5. [Observable Mocking](#observable-mocking) +6. [Component Mocking](#component-mocking) +7. [Router & Location Mocking](#router--location-mocking) +8. [Form Mocking](#form-mocking) +9. [LocalStorage & SessionStorage](#localstorage--sessionstorage) +10. [Advanced Patterns](#advanced-patterns) + +--- + +## Test Doubles Overview + +### Types of Test Doubles + +```typescript +// 1. DUMMY - Passed but never used +class DummyLogger implements Logger { + log(message: string): void { + // Does nothing + } +} + +// 2. STUB - Returns predefined responses +class StubUserService { + getUser(id: number): Observable { + return of({ id, name: 'Test User', email: 'test@example.com' }); + } +} + +// 3. SPY - Records interactions +const spy = jasmine.createSpy('getData'); +spy.and.returnValue('mock data'); + +// 4. MOCK - Pre-programmed with expectations +const mock = jasmine.createSpyObj('UserService', ['getUser', 'updateUser']); +mock.getUser.and.returnValue(of({ id: 1, name: 'John' })); + +// 5. FAKE - Working simplified implementation +class FakeAuthService { + private authenticated = false; + + login(): Observable { + this.authenticated = true; + return of(true); + } + + isAuthenticated(): boolean { + return this.authenticated; + } +} +``` + +### When to Use Each + +| Type | Use When | Example | +|------|----------|---------| +| Dummy | Need to pass parameters | Logger that's never called | +| Stub | Need consistent responses | API returning test data | +| Spy | Need to verify calls | Track method invocations | +| Mock | Need behavior verification | Service with expectations | +| Fake | Need working alternative | In-memory database | + +--- + +## Jasmine Spies + +### Creating Spies + +```typescript +// Method 1: Standalone spy +const spy = jasmine.createSpy('myFunction'); + +// Method 2: Spy on existing object +const service = new UserService(); +spyOn(service, 'getData'); + +// Method 3: Spy object (multiple methods) +const spyObj = jasmine.createSpyObj('UserService', [ + 'getUser', + 'updateUser', + 'deleteUser' +]); + +// Method 4: Spy object with properties +const spyObjWithProps = jasmine.createSpyObj('UserService', + ['getUser'], // methods + { currentUser: { id: 1, name: 'John' } } // properties +); +``` + +### Spy Return Values + +```typescript +// Return single value +spy.and.returnValue('mock value'); + +// Return multiple values in sequence +spy.and.returnValues('first', 'second', 'third'); + +// Return Observable +spy.and.returnValue(of({ id: 1, name: 'John' })); + +// Custom implementation +spy.and.callFake((arg) => { + if (arg === 'admin') { + return of({ role: 'admin' }); + } + return of({ role: 'user' }); +}); + +// Call original method +spy.and.callThrough(); + +// Throw error +spy.and.throwError('Something went wrong'); + +// Return promise +spy.and.resolveTo({ id: 1 }); // Promise.resolve +spy.and.rejectWith(new Error('Failed')); // Promise.reject +``` + +### Spy Assertions + +```typescript +// Basic assertions +expect(spy).toHaveBeenCalled(); +expect(spy).toHaveBeenCalledTimes(2); +expect(spy).not.toHaveBeenCalled(); + +// Argument assertions +expect(spy).toHaveBeenCalledWith('arg1', 'arg2'); +expect(spy).toHaveBeenCalledWith(jasmine.any(String)); +expect(spy).toHaveBeenCalledWith(jasmine.objectContaining({ id: 1 })); + +// Order assertions +expect(spy1).toHaveBeenCalledBefore(spy2); + +// Most recent call +expect(spy).toHaveBeenCalledWith('last call args'); + +// Reset spy +spy.calls.reset(); +``` + +### Spy Properties + +```typescript +const spy = jasmine.createSpy('myFunction'); +spy('arg1', 'arg2'); + +// Access call information +spy.calls.count(); // 1 +spy.calls.argsFor(0); // ['arg1', 'arg2'] +spy.calls.allArgs(); // [['arg1', 'arg2']] +spy.calls.all(); // Full call details +spy.calls.mostRecent(); // Last call details +spy.calls.first(); // First call details +``` + +--- + +## Service Mocking + +### Simple Service Mock + +```typescript +describe('UserComponent', () => { + let component: UserComponent; + let userService: jasmine.SpyObj; + + beforeEach(() => { + // Create spy object with all methods + const spy = jasmine.createSpyObj('UserService', [ + 'getUser', + 'getUsers', + 'updateUser', + 'deleteUser' + ]); + + TestBed.configureTestingModule({ + declarations: [UserComponent], + providers: [ + { provide: UserService, useValue: spy } + ] + }); + + userService = TestBed.inject(UserService) as jasmine.SpyObj; + component = new UserComponent(userService); + }); + + it('should load user on init', () => { + const mockUser = { id: 1, name: 'John Doe' }; + userService.getUser.and.returnValue(of(mockUser)); + + component.ngOnInit(); + + expect(userService.getUser).toHaveBeenCalledWith(1); + expect(component.user).toEqual(mockUser); + }); +}); +``` + +### Service with Properties + +```typescript +describe('AuthComponent', () => { + let authService: jasmine.SpyObj; + + beforeEach(() => { + authService = jasmine.createSpyObj( + 'AuthService', + ['login', 'logout'], + { + currentUser: of({ id: 1, name: 'John' }), + isAuthenticated: true + } + ); + }); + + it('should display current user', () => { + expect(authService.isAuthenticated).toBe(true); + + authService.currentUser.subscribe(user => { + expect(user.name).toBe('John'); + }); + }); +}); +``` + +### Mocking Service Dependencies + +```typescript +describe('ProductService', () => { + let service: ProductService; + let http: jasmine.SpyObj; + let cache: jasmine.SpyObj; + let logger: jasmine.SpyObj; + + beforeEach(() => { + const httpSpy = jasmine.createSpyObj('HttpClient', ['get', 'post', 'put', 'delete']); + const cacheSpy = jasmine.createSpyObj('CacheService', ['get', 'set', 'clear']); + const loggerSpy = jasmine.createSpyObj('LoggerService', ['log', 'error']); + + TestBed.configureTestingModule({ + providers: [ + ProductService, + { provide: HttpClient, useValue: httpSpy }, + { provide: CacheService, useValue: cacheSpy }, + { provide: LoggerService, useValue: loggerSpy } + ] + }); + + service = TestBed.inject(ProductService); + http = TestBed.inject(HttpClient) as jasmine.SpyObj; + cache = TestBed.inject(CacheService) as jasmine.SpyObj; + logger = TestBed.inject(LoggerService) as jasmine.SpyObj; + }); + + it('should use cached data when available', () => { + const cachedProduct = { id: 1, name: 'Cached Product' }; + cache.get.and.returnValue(cachedProduct); + + service.getProduct(1).subscribe(product => { + expect(product).toEqual(cachedProduct); + expect(http.get).not.toHaveBeenCalled(); + expect(logger.log).toHaveBeenCalledWith('Using cached product'); + }); + }); +}); +``` + +--- + +## HTTP Mocking + +### HttpClientTestingModule Setup + +```typescript +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +describe('ApiService', () => { + let service: ApiService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ApiService] + }); + + service = TestBed.inject(ApiService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); // Verify no outstanding requests + }); +}); +``` + +### GET Request Mocking + +```typescript +it('should fetch users', () => { + const mockUsers = [ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' } + ]; + + service.getUsers().subscribe(users => { + expect(users).toEqual(mockUsers); + expect(users.length).toBe(2); + }); + + const req = httpMock.expectOne('/api/users'); + expect(req.request.method).toBe('GET'); + req.flush(mockUsers); +}); + +it('should handle query parameters', () => { + service.getUsers({ role: 'admin', active: true }).subscribe(); + + const req = httpMock.expectOne(req => + req.url === '/api/users' && + req.params.get('role') === 'admin' && + req.params.get('active') === 'true' + ); + req.flush([]); +}); +``` + +### POST/PUT/DELETE Mocking + +```typescript +it('should create user', () => { + const newUser = { name: 'John', email: 'john@example.com' }; + const createdUser = { id: 1, ...newUser }; + + service.createUser(newUser).subscribe(user => { + expect(user).toEqual(createdUser); + }); + + const req = httpMock.expectOne('/api/users'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(newUser); + req.flush(createdUser); +}); + +it('should update user', () => { + const updatedUser = { id: 1, name: 'John Updated' }; + + service.updateUser(1, updatedUser).subscribe(); + + const req = httpMock.expectOne('/api/users/1'); + expect(req.request.method).toBe('PUT'); + req.flush(updatedUser); +}); + +it('should delete user', () => { + service.deleteUser(1).subscribe(); + + const req = httpMock.expectOne('/api/users/1'); + expect(req.request.method).toBe('DELETE'); + req.flush(null); +}); +``` + +### HTTP Headers Mocking + +```typescript +it('should send authorization header', () => { + service.getProtectedData().subscribe(); + + const req = httpMock.expectOne('/api/protected'); + expect(req.request.headers.get('Authorization')).toBe('Bearer fake-token'); + req.flush({ data: 'secret' }); +}); + +it('should set custom headers', () => { + service.uploadFile(new FormData()).subscribe(); + + const req = httpMock.expectOne('/api/upload'); + expect(req.request.headers.get('Content-Type')).toContain('multipart/form-data'); + req.flush({ success: true }); +}); +``` + +### HTTP Error Handling + +```typescript +it('should handle 404 error', () => { + service.getUser(999).subscribe({ + next: () => fail('should have failed'), + error: (error) => { + expect(error.status).toBe(404); + expect(error.statusText).toBe('Not Found'); + } + }); + + const req = httpMock.expectOne('/api/users/999'); + req.flush('User not found', { + status: 404, + statusText: 'Not Found' + }); +}); + +it('should handle 500 error with message', () => { + service.updateUser(1, {}).subscribe({ + error: (error) => { + expect(error.status).toBe(500); + expect(error.error.message).toBe('Server error'); + } + }); + + const req = httpMock.expectOne('/api/users/1'); + req.flush( + { message: 'Server error' }, + { status: 500, statusText: 'Internal Server Error' } + ); +}); + +it('should handle network error', () => { + service.getUsers().subscribe({ + error: (error) => { + expect(error.error.type).toBe('error'); + } + }); + + const req = httpMock.expectOne('/api/users'); + req.error(new ErrorEvent('Network error')); +}); +``` + +### Multiple HTTP Requests + +```typescript +it('should handle multiple simultaneous requests', () => { + service.getUser(1).subscribe(); + service.getUser(2).subscribe(); + service.getUser(3).subscribe(); + + const requests = httpMock.match(req => req.url.startsWith('/api/users/')); + expect(requests.length).toBe(3); + + requests[0].flush({ id: 1, name: 'User 1' }); + requests[1].flush({ id: 2, name: 'User 2' }); + requests[2].flush({ id: 3, name: 'User 3' }); +}); + +it('should handle sequential dependent requests', fakeAsync(() => { + let userData: any; + let postsData: any; + + // First request - get user + service.getUser(1).subscribe(user => { + userData = user; + }); + + let req = httpMock.expectOne('/api/users/1'); + req.flush({ id: 1, name: 'John' }); + tick(); + + // Second request - get user's posts (depends on first) + service.getUserPosts(userData.id).subscribe(posts => { + postsData = posts; + }); + + req = httpMock.expectOne('/api/users/1/posts'); + req.flush([{ id: 1, title: 'Post 1' }]); + tick(); + + expect(userData).toBeDefined(); + expect(postsData.length).toBe(1); +})); +``` + +--- + +## Observable Mocking + +### Basic Observable Mocking + +```typescript +import { of, throwError, EMPTY, NEVER } from 'rxjs'; +import { delay } from 'rxjs/operators'; + +// Success response +service.getData.and.returnValue(of({ data: 'test' })); + +// Error response +service.getData.and.returnValue( + throwError(() => new Error('Failed')) +); + +// Empty observable +service.getData.and.returnValue(EMPTY); + +// Never completing observable +service.getData.and.returnValue(NEVER); + +// Delayed response +service.getData.and.returnValue( + of({ data: 'test' }).pipe(delay(1000)) +); +``` + +### Subject Mocking + +```typescript +import { Subject, BehaviorSubject, ReplaySubject } from 'rxjs'; + +describe('NotificationService', () => { + let service: NotificationService; + let notificationSubject: Subject; + + beforeEach(() => { + notificationSubject = new Subject(); + + const spy = jasmine.createSpyObj('NotificationService', + ['notify'], + { notifications$: notificationSubject.asObservable() } + ); + + service = spy; + }); + + it('should receive notifications', (done) => { + const messages: string[] = []; + + service.notifications$.subscribe(msg => { + messages.push(msg); + + if (messages.length === 2) { + expect(messages).toEqual(['Hello', 'World']); + done(); + } + }); + + notificationSubject.next('Hello'); + notificationSubject.next('World'); + }); +}); + +// BehaviorSubject with initial value +it('should have initial value', () => { + const subject = new BehaviorSubject(0); + const spy = jasmine.createSpyObj('CounterService', + ['increment'], + { count$: subject.asObservable() } + ); + + spy.count$.subscribe(count => { + expect(count).toBe(0); + }); + + subject.next(1); + + spy.count$.subscribe(count => { + expect(count).toBe(1); + }); +}); +``` + +### Async Observable Testing + +```typescript +it('should handle async data', fakeAsync(() => { + let result: any; + + service.getData().pipe( + delay(1000) + ).subscribe(data => { + result = data; + }); + + expect(result).toBeUndefined(); + + tick(1000); // Advance time + + expect(result).toEqual({ data: 'test' }); +})); + +it('should handle debounced input', fakeAsync(() => { + const spy = jasmine.createSpy('callback'); + + component.searchInput.pipe( + debounceTime(300) + ).subscribe(spy); + + component.searchInput.next('a'); + tick(100); + component.searchInput.next('ab'); + tick(100); + component.searchInput.next('abc'); + tick(300); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('abc'); +})); +``` + +--- + +## Component Mocking + +### Mock Child Components + +```typescript +// Create mock component +@Component({ + selector: 'app-child', + template: '
Mock Child
' +}) +class MockChildComponent { + @Input() data: any; + @Output() action = new EventEmitter(); +} + +describe('ParentComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + ParentComponent, + MockChildComponent // Use mock instead of real + ] + }).compileComponents(); + }); + + it('should pass data to child', () => { + const fixture = TestBed.createComponent(ParentComponent); + const component = fixture.componentInstance; + const childDebugElement = fixture.debugElement.query( + By.directive(MockChildComponent) + ); + const childComponent = childDebugElement.componentInstance as MockChildComponent; + + component.parentData = { value: 'test' }; + fixture.detectChanges(); + + expect(childComponent.data).toEqual({ value: 'test' }); + }); + + it('should handle child events', () => { + const fixture = TestBed.createComponent(ParentComponent); + const component = fixture.componentInstance; + const childDebugElement = fixture.debugElement.query( + By.directive(MockChildComponent) + ); + const childComponent = childDebugElement.componentInstance as MockChildComponent; + + spyOn(component, 'handleAction'); + + childComponent.action.emit('test-data'); + + expect(component.handleAction).toHaveBeenCalledWith('test-data'); + }); +}); +``` + +### NO_ERRORS_SCHEMA + +```typescript +// Alternative: Use NO_ERRORS_SCHEMA to ignore child components +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +describe('ParentComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ParentComponent], + schemas: [NO_ERRORS_SCHEMA] // Ignore unknown elements + }).compileComponents(); + }); + + it('should work without child component implementation', () => { + const fixture = TestBed.createComponent(ParentComponent); + expect(fixture.componentInstance).toBeTruthy(); + }); +}); +``` + +--- + +## Router & Location Mocking + +### Router Mocking + +```typescript +import { Router } from '@angular/router'; + +describe('NavigationComponent', () => { + let component: NavigationComponent; + let router: jasmine.SpyObj; + + beforeEach(() => { + const routerSpy = jasmine.createSpyObj('Router', [ + 'navigate', + 'navigateByUrl' + ]); + + TestBed.configureTestingModule({ + declarations: [NavigationComponent], + providers: [ + { provide: Router, useValue: routerSpy } + ] + }); + + router = TestBed.inject(Router) as jasmine.SpyObj; + component = new NavigationComponent(router); + }); + + it('should navigate to dashboard', () => { + component.goToDashboard(); + + expect(router.navigate).toHaveBeenCalledWith(['/dashboard']); + }); + + it('should navigate with query params', () => { + component.goToProducts({ category: 'electronics' }); + + expect(router.navigate).toHaveBeenCalledWith( + ['/products'], + { queryParams: { category: 'electronics' } } + ); + }); +}); +``` + +### ActivatedRoute Mocking + +```typescript +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; + +describe('ProductDetailComponent', () => { + let component: ProductDetailComponent; + let route: ActivatedRoute; + + beforeEach(() => { + const routeMock = { + snapshot: { + paramMap: { + get: (key: string) => '123' + } + }, + params: of({ id: '123' }), + queryParams: of({ tab: 'details' }), + data: of({ product: { id: 1, name: 'Test' } }) + }; + + TestBed.configureTestingModule({ + declarations: [ProductDetailComponent], + providers: [ + { provide: ActivatedRoute, useValue: routeMock } + ] + }); + + route = TestBed.inject(ActivatedRoute); + component = new ProductDetailComponent(route); + }); + + it('should read route params', () => { + expect(component.productId).toBe('123'); + }); + + it('should subscribe to query params', () => { + component.ngOnInit(); + expect(component.activeTab).toBe('details'); + }); +}); +``` + +### RouterTestingModule + +```typescript +import { RouterTestingModule } from '@angular/router/testing'; +import { Location } from '@angular/common'; +import { Router } from '@angular/router'; + +describe('Navigation Integration', () => { + let router: Router; + let location: Location; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([ + { path: 'home', component: HomeComponent }, + { path: 'about', component: AboutComponent } + ]) + ], + declarations: [HomeComponent, AboutComponent] + }).compileComponents(); + + router = TestBed.inject(Router); + location = TestBed.inject(Location); + }); + + it('should navigate to home', fakeAsync(() => { + router.navigate(['/home']); + tick(); + + expect(location.path()).toBe('/home'); + })); +}); +``` + +--- + +## Form Mocking + +### FormBuilder & FormControl Mocking + +```typescript +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +describe('UserFormComponent', () => { + let component: UserFormComponent; + let fb: FormBuilder; + + beforeEach(() => { + fb = new FormBuilder(); + component = new UserFormComponent(fb); + component.ngOnInit(); + }); + + it('should create form with controls', () => { + expect(component.userForm.get('name')).toBeDefined(); + expect(component.userForm.get('email')).toBeDefined(); + }); + + it('should validate required fields', () => { + const name = component.userForm.get('name'); + expect(name?.valid).toBeFalsy(); + expect(name?.hasError('required')).toBeTruthy(); + + name?.setValue('John Doe'); + expect(name?.valid).toBeTruthy(); + }); + + it('should validate email format', () => { + const email = component.userForm.get('email'); + + email?.setValue('invalid-email'); + expect(email?.hasError('email')).toBeTruthy(); + + email?.setValue('valid@example.com'); + expect(email?.valid).toBeTruthy(); + }); +}); +``` + +### Custom Validators Mocking + +```typescript +// Custom validator +export function ageValidator(min: number, max: number): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const age = control.value; + if (age < min || age > max) { + return { ageRange: { min, max, actual: age } }; + } + return null; + }; +} + +// Testing custom validator +describe('Age Validator', () => { + it('should validate age range', () => { + const control = new FormControl(25); + const validator = ageValidator(18, 65); + + expect(validator(control)).toBeNull(); // Valid + + control.setValue(10); + expect(validator(control)).toEqual({ + ageRange: { min: 18, max: 65, actual: 10 } + }); + + control.setValue(70); + expect(validator(control)).toEqual({ + ageRange: { min: 18, max: 65, actual: 70 } + }); + }); +}); +``` + +--- + +## LocalStorage & SessionStorage + +### Storage Mocking + +```typescript +describe('StorageService', () => { + let service: StorageService; + let store: { [key: string]: string }; + + beforeEach(() => { + store = {}; + + spyOn(localStorage, 'getItem').and.callFake((key: string) => { + return store[key] || null; + }); + + spyOn(localStorage, 'setItem').and.callFake((key: string, value: string) => { + store[key] = value; + }); + + spyOn(localStorage, 'removeItem').and.callFake((key: string) => { + delete store[key]; + }); + + spyOn(localStorage, 'clear').and.callFake(() => { + store = {}; + }); + + service = new StorageService(); + }); + + it('should save data to localStorage', () => { + service.setItem('key', 'value'); + + expect(localStorage.setItem).toHaveBeenCalledWith('key', 'value'); + expect(store['key']).toBe('value'); + }); + + it('should retrieve data from localStorage', () => { + store['key'] = 'value'; + + const result = service.getItem('key'); + + expect(localStorage.getItem).toHaveBeenCalledWith('key'); + expect(result).toBe('value'); + }); + + it('should remove item from localStorage', () => { + store['key'] = 'value'; + + service.removeItem('key'); + + expect(localStorage.removeItem).toHaveBeenCalledWith('key'); + expect(store['key']).toBeUndefined(); + }); +}); +``` + +--- + +## Advanced Patterns + +### Partial Mocking + +```typescript +// Mock only specific methods, keep others real +describe('ComplexService', () => { + let service: ComplexService; + + beforeEach(() => { + service = new ComplexService(); + spyOn(service, 'expensiveOperation').and.returnValue('mocked'); + // Other methods use real implementation + }); + + it('should use mocked method', () => { + const result = service.doSomething(); + expect(service.expensiveOperation).toHaveBeenCalled(); + }); +}); +``` + +### Spy Chaining + +```typescript +it('should chain multiple service calls', () => { + const userService = jasmine.createSpyObj('UserService', ['getUser']); + const orderService = jasmine.createSpyObj('OrderService', ['getOrders']); + + userService.getUser.and.returnValue(of({ id: 1, name: 'John' })); + orderService.getOrders.and.returnValue(of([{ id: 1, total: 100 }])); + + component.loadUserData(1); + + expect(userService.getUser).toHaveBeenCalledWith(1); + expect(orderService.getOrders).toHaveBeenCalledWith(1); +}); +``` + +### Conditional Mocking + +```typescript +it('should mock based on arguments', () => { + const service = jasmine.createSpyObj('ApiService', ['getData']); + + service.getData.and.callFake((id: number) => { + if (id === 1) { + return of({ data: 'admin' }); + } else if (id === 2) { + return of({ data: 'user' }); + } else { + return throwError(() => new Error('Not found')); + } + }); + + service.getData(1).subscribe(result => { + expect(result.data).toBe('admin'); + }); + + service.getData(2).subscribe(result => { + expect(result.data).toBe('user'); + }); + + service.getData(999).subscribe({ + error: (error) => { + expect(error.message).toBe('Not found'); + } + }); +}); +``` + +### Dynamic Mock Configuration + +```typescript +describe('ConfigurableService', () => { + let service: MyService; + let apiService: jasmine.SpyObj; + + function configureTest(config: { + shouldSucceed: boolean; + delay?: number; + returnValue?: any; + }) { + if (config.shouldSucceed) { + let obs = of(config.returnValue || { success: true }); + if (config.delay) { + obs = obs.pipe(delay(config.delay)); + } + apiService.getData.and.returnValue(obs); + } else { + apiService.getData.and.returnValue( + throwError(() => new Error('Failed')) + ); + } + } + + beforeEach(() => { + apiService = jasmine.createSpyObj('ApiService', ['getData']); + service = new MyService(apiService); + }); + + it('should handle success case', () => { + configureTest({ + shouldSucceed: true, + returnValue: { data: 'test' } + }); + + service.loadData().subscribe(result => { + expect(result.data).toBe('test'); + }); + }); + + it('should handle error case', () => { + configureTest({ shouldSucceed: false }); + + service.loadData().subscribe({ + error: (error) => { + expect(error.message).toBe('Failed'); + } + }); + }); +}); +``` + +--- + +## Best Practices + +### ✅ DO + +```typescript +// Use spy objects for dependencies +const spy = jasmine.createSpyObj('Service', ['method']); + +// Reset spies between tests +afterEach(() => { + spy.calls.reset(); +}); + +// Use meaningful test data +const mockUser = { id: 1, name: 'John Doe', email: 'john@example.com' }; + +// Mock at the right level +// Component tests: Mock services +// Service tests: Mock HTTP +// E2E tests: Mock nothing + +// Verify spy calls explicitly +expect(spy.method).toHaveBeenCalledWith('expected', 'arguments'); +``` + +### ❌ DON'T + +```typescript +// Don't use real services in unit tests +TestBed.configureTestingModule({ + providers: [RealDatabaseService] // ❌ Bad +}); + +// Don't over-mock +spyOn(Math, 'random'); // ❌ Don't mock language features +spyOn(Array.prototype, 'push'); // ❌ Don't mock prototypes + +// Don't create brittle mocks +spy.and.returnValue({ /* huge object */ }); // ❌ Too specific + +// Don't forget to verify HTTP calls +// Missing httpMock.verify() in afterEach ❌ +``` + +--- + +## Quick Reference + +### Common Spy Patterns + +```typescript +// Return value +spy.and.returnValue(value); + +// Return Observable +spy.and.returnValue(of(value)); + +// Return Promise +spy.and.resolveTo(value); + +// Throw error +spy.and.throwError('error'); + +// Custom logic +spy.and.callFake((arg) => { /* logic */ }); + +// Call real method +spy.and.callThrough(); +``` + +### Common Assertions + +```typescript +expect(spy).toHaveBeenCalled(); +expect(spy).toHaveBeenCalledTimes(n); +expect(spy).toHaveBeenCalledWith(arg1, arg2); +expect(spy).not.toHaveBeenCalled(); +``` + +### HTTP Testing + +```typescript +const req = httpMock.expectOne(url); +expect(req.request.method).toBe('GET'); +req.flush(mockData); +httpMock.verify(); // In afterEach +``` + +--- + +*Master mocking for clean, maintainable tests! 🎭* diff --git a/skills/testing-strategies/SKILL.md b/skills/testing-strategies/SKILL.md new file mode 100644 index 0000000..0f443e9 --- /dev/null +++ b/skills/testing-strategies/SKILL.md @@ -0,0 +1,1094 @@ +# 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; + + 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; + let userService: jasmine.SpyObj; + + 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; + 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; + let router: jasmine.SpyObj; + + 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; + router = TestBed.inject(Router) as jasmine.SpyObj; + }); + + 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; + let element: DebugElement; + + @Component({ + template: '
Test
' + }) + 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; + 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; + 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; + 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(data: T): Observable { + return of(data); +} + +/** + * Create a mock Observable that throws an error + */ +export function createMockError(error: any): Observable { + return throwError(() => error); +} + +/** + * Click a button and wait for async operations + */ +export async function clickButton( + fixture: ComponentFixture, + selector: string +): Promise { + 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( + baseName: string, + methodNames: (keyof T)[] +): jasmine.SpyObj { + 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 { + 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; + + 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! 🛡️*