Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:25:05 +08:00
commit 51724191c4
8 changed files with 3507 additions and 0 deletions

507
commands/generate-tests.md Normal file
View File

@@ -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] <name>
```
## 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<LoginComponent>;
let authService: jasmine.SpyObj<AuthService>;
let router: jasmine.SpyObj<Router>;
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<AuthService>;
router = TestBed.inject(Router) as jasmine.SpyObj<Router>;
});
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: ['<rootDir>/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! 🧪*

377
commands/run-e2e.md Normal file
View File

@@ -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<void>;
logout(): Chainable<void>;
getBySel(selector: string): Chainable<JQuery<HTMLElement>>;
checkAccessibility(): Chainable<void>;
}
}
}
// 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! 🎭*