Przejdź do treści

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

# Generate coverage report
npm run test:coverage

# View report
open coverage/index.html

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'

Dalsze zasoby