Testowanie¶
Wprowadzenie¶
System Masaku używa Jest do testów frontendowych i xUnit do testów backendowych.
Frontend Testing (Jest + Angular)¶
Konfiguracja¶
// jest.config.ts
export default {
preset: 'jest-preset-angular',
setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
coverageDirectory: 'coverage',
coverageReporters: ['html', 'text', 'lcov'],
collectCoverageFrom: [
'projects/**/*.ts',
'!projects/**/*.spec.ts',
'!projects/**/index.ts'
]
};
Uruchamianie testów¶
# Wszystkie testy
npm test
# Testy z coverage
npm run test:coverage
# Testy w CI (single run)
npm run test:ci
# Testy dla konkretnej biblioteki
nx test budgets
# Watch mode
npm test -- --watch
# Specific file
npm test -- budget.service.spec.ts
Unit Testing - Services¶
// budget.service.spec.ts
describe('BudgetService', () => {
let service: BudgetService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [BudgetService]
});
service = TestBed.inject(BudgetService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should fetch all budgets', (done) => {
const mockBudgets: Budget[] = [
{
id: '1',
name: 'Test Budget',
amount: 1000,
startDate: new Date(),
userId: 'user1',
shared: false,
currency: 'EUR',
status: 'Active'
}
];
service.getAll().subscribe(budgets => {
expect(budgets).toEqual(mockBudgets);
expect(budgets.length).toBe(1);
done();
});
const req = httpMock.expectOne('/api/budgets');
expect(req.request.method).toBe('GET');
req.flush(mockBudgets);
});
it('should create a budget', (done) => {
const newBudget: CreateBudgetDto = {
name: 'New Budget',
amount: 2000,
startDate: new Date(),
shared: false,
currency: 'EUR'
};
service.create(newBudget).subscribe(budget => {
expect(budget.name).toBe(newBudget.name);
done();
});
const req = httpMock.expectOne('/api/budgets');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newBudget);
req.flush({ ...newBudget, id: '2', userId: 'user1', status: 'Active' });
});
it('should handle errors', (done) => {
service.getAll().subscribe({
next: () => fail('should have failed'),
error: (error) => {
expect(error.status).toBe(500);
done();
}
});
const req = httpMock.expectOne('/api/budgets');
req.flush('Server error', { status: 500, statusText: 'Server Error' });
});
});
Unit Testing - Components¶
// budget-list.component.spec.ts
describe('BudgetListComponent', () => {
let component: BudgetListComponent;
let fixture: ComponentFixture<BudgetListComponent>;
let store: Store;
let mockBudgetService: jasmine.SpyObj<BudgetService>;
beforeEach(async () => {
mockBudgetService = jasmine.createSpyObj('BudgetService', ['getAll', 'delete']);
await TestBed.configureTestingModule({
declarations: [BudgetListComponent],
imports: [NgxsModule.forRoot([BudgetState])],
providers: [
{ provide: BudgetService, useValue: mockBudgetService }
]
}).compileComponents();
fixture = TestBed.createComponent(BudgetListComponent);
component = fixture.componentInstance;
store = TestBed.inject(Store);
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load budgets on init', () => {
spyOn(store, 'dispatch');
component.ngOnInit();
expect(store.dispatch).toHaveBeenCalledWith(new LoadBudgets());
});
it('should display budgets in template', () => {
const mockBudgets: Budget[] = [
{ id: '1', name: 'Budget 1', amount: 1000 },
{ id: '2', name: 'Budget 2', amount: 2000 }
];
store.reset({
budgets: {
budgets: mockBudgets,
loading: false,
error: null
}
});
fixture.detectChanges();
const compiled = fixture.nativeElement;
const budgetElements = compiled.querySelectorAll('.budget-item');
expect(budgetElements.length).toBe(2);
expect(budgetElements[0].textContent).toContain('Budget 1');
expect(budgetElements[1].textContent).toContain('Budget 2');
});
it('should call delete on button click', () => {
const budget: Budget = { id: '1', name: 'Test', amount: 1000 };
spyOn(store, 'dispatch');
component.deleteBudget(budget);
expect(store.dispatch).toHaveBeenCalledWith(new DeleteBudget(budget.id));
});
});
Unit Testing - NGXS State¶
// budget.state.spec.ts
describe('BudgetState', () => {
let store: Store;
let mockBudgetService: jasmine.SpyObj<BudgetService>;
beforeEach(() => {
mockBudgetService = jasmine.createSpyObj('BudgetService', ['getAll', 'create']);
TestBed.configureTestingModule({
imports: [NgxsModule.forRoot([BudgetState])],
providers: [
{ provide: BudgetService, useValue: mockBudgetService }
]
});
store = TestBed.inject(Store);
});
it('should load budgets', async () => {
const mockBudgets: Budget[] = [
{ id: '1', name: 'Test', amount: 1000 }
];
mockBudgetService.getAll.and.returnValue(of(mockBudgets));
await store.dispatch(new LoadBudgets()).toPromise();
const state = store.selectSnapshot(BudgetState.getBudgets);
expect(state).toEqual(mockBudgets);
});
it('should set loading state', async () => {
mockBudgetService.getAll.and.returnValue(of([]));
const promise = store.dispatch(new LoadBudgets()).toPromise();
let loading = store.selectSnapshot(BudgetState.isLoading);
expect(loading).toBe(true);
await promise;
loading = store.selectSnapshot(BudgetState.isLoading);
expect(loading).toBe(false);
});
it('should handle errors', async () => {
const error = new Error('Failed to load');
mockBudgetService.getAll.and.returnValue(throwError(() => error));
await store.dispatch(new LoadBudgets()).toPromise();
const state = store.selectSnapshot(state => state.budgets.error);
expect(state).toBe('Failed to load');
});
});
Integration Testing (E2E) - Cypress (opcjonalnie)¶
// cypress/e2e/budget-flow.cy.ts
describe('Budget Flow', () => {
beforeEach(() => {
cy.login('test@masaku.de', 'password');
cy.visit('/budgets');
});
it('should create a new budget', () => {
cy.get('[data-cy=create-budget-btn]').click();
cy.get('[data-cy=budget-name]').type('My Budget');
cy.get('[data-cy=budget-amount]').type('5000');
cy.get('[data-cy=budget-currency]').select('EUR');
cy.get('[data-cy=save-budget-btn]').click();
cy.get('[data-cy=budget-list]').should('contain', 'My Budget');
});
it('should edit a budget', () => {
cy.get('[data-cy=budget-item]:first').click();
cy.get('[data-cy=edit-budget-btn]').click();
cy.get('[data-cy=budget-name]').clear().type('Updated Budget');
cy.get('[data-cy=save-budget-btn]').click();
cy.get('[data-cy=budget-list]').should('contain', 'Updated Budget');
});
});
Backend Testing (xUnit + .NET)¶
Struktura testów¶
Masaku.Tests.Unit/
├── Services/
│ ├── BudgetServiceTests.cs
│ ├── ClientServiceTests.cs
│ └── ...
├── Repositories/
│ └── BudgetRepositoryTests.cs
└── Controllers/
└── BudgetsControllerTests.cs
Masaku.Tests.Integration/
└── API/
├── BudgetsApiTests.cs
└── ClientsApiTests.cs
Uruchamianie testów¶
# Wszystkie testy
dotnet test
# Unit testy
dotnet test Masaku.Tests.Unit
# Integration testy
dotnet test Masaku.Tests.Integration
# Z coverage
dotnet test /p:CollectCoverage=true /p:CoverageReporter=html
# Specific test
dotnet test --filter "FullyQualifiedName=Masaku.Tests.Unit.Services.BudgetServiceTests.CreateBudget_ShouldReturnBudget"
Unit Testing - Services¶
// BudgetServiceTests.cs
public class BudgetServiceTests
{
private readonly Mock<IBudgetRepository> _mockRepository;
private readonly Mock<IMapper> _mockMapper;
private readonly Mock<IValidator<CreateBudgetDto>> _mockValidator;
private readonly Mock<ILogger<BudgetService>> _mockLogger;
private readonly BudgetService _service;
public BudgetServiceTests()
{
_mockRepository = new Mock<IBudgetRepository>();
_mockMapper = new Mock<IMapper>();
_mockValidator = new Mock<IValidator<CreateBudgetDto>>();
_mockLogger = new Mock<ILogger<BudgetService>>();
_service = new BudgetService(
_mockRepository.Object,
_mockMapper.Object,
_mockValidator.Object,
_mockLogger.Object
);
}
[Fact]
public async Task GetByIdAsync_WhenBudgetExists_ShouldReturnBudget()
{
// Arrange
var budgetId = 1;
var budget = new Budget { Id = budgetId, Name = "Test Budget" };
var budgetDto = new BudgetDto { Id = budgetId, Name = "Test Budget" };
_mockRepository
.Setup(r => r.GetByIdAsync(budgetId))
.ReturnsAsync(budget);
_mockMapper
.Setup(m => m.Map<BudgetDto>(budget))
.Returns(budgetDto);
// Act
var result = await _service.GetByIdAsync(budgetId);
// Assert
Assert.NotNull(result);
Assert.Equal(budgetId, result.Id);
Assert.Equal("Test Budget", result.Name);
_mockRepository.Verify(r => r.GetByIdAsync(budgetId), Times.Once);
_mockMapper.Verify(m => m.Map<BudgetDto>(budget), Times.Once);
}
[Fact]
public async Task CreateAsync_WhenValidationFails_ShouldThrowValidationException()
{
// Arrange
var dto = new CreateBudgetDto { Name = "Te", Amount = -100 };
var validationResult = new ValidationResult(new[]
{
new ValidationFailure("Name", "Name too short"),
new ValidationFailure("Amount", "Amount must be positive")
});
_mockValidator
.Setup(v => v.ValidateAsync(dto, default))
.ReturnsAsync(validationResult);
// Act & Assert
await Assert.ThrowsAsync<ValidationException>(
() => _service.CreateAsync("user1", dto)
);
_mockRepository.Verify(r => r.CreateAsync(It.IsAny<Budget>()), Times.Never);
}
[Fact]
public async Task CreateAsync_WhenValid_ShouldCreateBudget()
{
// Arrange
var userId = "user1";
var dto = new CreateBudgetDto { Name = "Test", Amount = 1000 };
var budget = new Budget { Id = 1, Name = "Test", Amount = 1000, UserId = userId };
var budgetDto = new BudgetDto { Id = 1, Name = "Test", Amount = 1000 };
_mockValidator
.Setup(v => v.ValidateAsync(dto, default))
.ReturnsAsync(new ValidationResult());
_mockMapper
.Setup(m => m.Map<Budget>(dto))
.Returns(budget);
_mockMapper
.Setup(m => m.Map<BudgetDto>(budget))
.Returns(budgetDto);
_mockRepository
.Setup(r => r.CreateAsync(It.IsAny<Budget>()))
.Returns(Task.CompletedTask);
_mockRepository
.Setup(r => r.SaveChangesAsync())
.ReturnsAsync(1);
// Act
var result = await _service.CreateAsync(userId, dto);
// Assert
Assert.NotNull(result);
Assert.Equal(1, result.Id);
Assert.Equal("Test", result.Name);
_mockRepository.Verify(r => r.CreateAsync(It.IsAny<Budget>()), Times.Once);
_mockRepository.Verify(r => r.SaveChangesAsync(), Times.Once);
}
}
Unit Testing - Controllers¶
// BudgetsControllerTests.cs
public class BudgetsControllerTests
{
private readonly Mock<IBudgetService> _mockService;
private readonly Mock<ILogger<BudgetsController>> _mockLogger;
private readonly BudgetsController _controller;
public BudgetsControllerTests()
{
_mockService = new Mock<IBudgetService>();
_mockLogger = new Mock<ILogger<BudgetsController>>();
_controller = new BudgetsController(_mockService.Object, _mockLogger.Object);
// Setup mock user
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, "user1")
}));
_controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = user }
};
}
[Fact]
public async Task GetAll_ShouldReturnOkWithBudgets()
{
// Arrange
var budgets = new List<BudgetDto>
{
new BudgetDto { Id = 1, Name = "Budget 1" },
new BudgetDto { Id = 2, Name = "Budget 2" }
};
_mockService
.Setup(s => s.GetByUserIdAsync("user1"))
.ReturnsAsync(budgets);
// Act
var result = await _controller.GetAll();
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var returnValue = Assert.IsAssignableFrom<IEnumerable<BudgetDto>>(okResult.Value);
Assert.Equal(2, returnValue.Count());
}
[Fact]
public async Task GetById_WhenBudgetExists_ShouldReturnOk()
{
// Arrange
var budget = new BudgetDto { Id = 1, Name = "Test" };
_mockService.Setup(s => s.GetByIdAsync(1)).ReturnsAsync(budget);
// Act
var result = await _controller.GetById(1);
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var returnValue = Assert.IsType<BudgetDto>(okResult.Value);
Assert.Equal(1, returnValue.Id);
}
[Fact]
public async Task GetById_WhenBudgetNotFound_ShouldReturnNotFound()
{
// Arrange
_mockService.Setup(s => s.GetByIdAsync(999)).ReturnsAsync((BudgetDto?)null);
// Act
var result = await _controller.GetById(999);
// Assert
Assert.IsType<NotFoundResult>(result);
}
[Fact]
public async Task Create_WhenValid_ShouldReturnCreated()
{
// Arrange
var dto = new CreateBudgetDto { Name = "Test", Amount = 1000 };
var budget = new BudgetDto { Id = 1, Name = "Test", Amount = 1000 };
_mockService
.Setup(s => s.CreateAsync("user1", dto))
.ReturnsAsync(budget);
// Act
var result = await _controller.Create(dto);
// Assert
var createdResult = Assert.IsType<CreatedAtActionResult>(result);
Assert.Equal(nameof(BudgetsController.GetById), createdResult.ActionName);
Assert.Equal(1, ((BudgetDto)createdResult.Value!).Id);
}
}
Integration Testing¶
// BudgetsApiTests.cs
public class BudgetsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public BudgetsApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
_client = factory.CreateClient();
}
[Fact]
public async Task GetBudgets_ShouldReturnOk()
{
// Act
var response = await _client.GetAsync("/api/budgets");
// Assert
response.EnsureSuccessStatusCode();
Assert.Equal("application/json; charset=utf-8",
response.Content.Headers.ContentType?.ToString());
var content = await response.Content.ReadAsStringAsync();
var budgets = JsonSerializer.Deserialize<List<BudgetDto>>(content);
Assert.NotNull(budgets);
}
[Fact]
public async Task CreateBudget_ShouldReturnCreated()
{
// Arrange
var dto = new CreateBudgetDto
{
Name = "Test Budget",
Amount = 1000,
StartDate = DateTime.UtcNow,
Currency = "EUR"
};
var content = new StringContent(
JsonSerializer.Serialize(dto),
Encoding.UTF8,
"application/json"
);
// Act
var response = await _client.PostAsync("/api/budgets", content);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
}
}
Test Coverage¶
Frontend¶
Target: 80%+ code coverage
Backend¶
# Generate coverage report
dotnet test /p:CollectCoverage=true /p:CoverageReporter=html
# View report
open coverage/index.html
Target: 80%+ code coverage
Best Practices¶
1. AAA Pattern (Arrange, Act, Assert)¶
[Fact]
public async Task Test_Method()
{
// Arrange - setup
var input = "test";
// Act - execute
var result = await _service.DoSomething(input);
// Assert - verify
Assert.Equal("expected", result);
}
2. Test Naming Convention¶
MethodName_Scenario_ExpectedBehavior
Przykłady:
- GetById_WhenBudgetExists_ShouldReturnBudget
- Create_WhenValidationFails_ShouldThrowException
- Delete_WhenBudgetNotFound_ShouldReturnFalse
3. Isolacja testów¶
Każdy test powinien być niezależny i nie polegać na innych testach.
4. Test doubles¶
- Mock: Weryfikuje interakcje (verify)
- Stub: Zwraca predefiniowane wartości
- Fake: Uproszczona implementacja
5. Test only public API¶
Nie testuj prywatnych metod bezpośrednio.
Continuous Integration¶
# azure-pipelines.yml
stages:
- stage: Test
jobs:
- job: FrontendTests
steps:
- script: npm install
- script: npm run test:ci
- script: npm run lint
- job: BackendTests
steps:
- script: dotnet restore
- script: dotnet test --logger trx
- task: PublishTestResults@2
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '**/*.trx'