Skip to content

Testing

This document provides comprehensive guidance for testing the Stratpoint Timesheet Application, including unit tests, integration tests, end-to-end tests, and testing best practices.

Testing Strategy

Testing Pyramid

graph TB
    A[End-to-End Tests] --> B[Integration Tests]
    B --> C[Unit Tests]

    D[Manual Testing] --> A
    E[API Testing] --> B
    F[Component Testing] --> C

    G[Performance Testing] --> H[Load Testing]
    G --> I[Stress Testing]

    J[Security Testing] --> K[Vulnerability Scanning]
    J --> L[Penetration Testing]

Test Types and Coverage

Test Type Coverage Target Tools Frequency
Unit Tests 90%+ PHPUnit, Jest Every commit
Integration Tests 80%+ PHPUnit, Cypress Every PR
End-to-End Tests Critical paths Cypress, Selenium Daily
API Tests All endpoints Postman, PHPUnit Every PR
Performance Tests Key scenarios JMeter, Artillery Weekly
Security Tests All features OWASP ZAP, SonarQube Weekly

PHP/Laravel Testing

Unit Testing Setup

<?php

namespace Tests\Unit\Services;

use App\Models\User;
use App\Models\Project;
use App\Models\Timesheet;
use App\Services\TimesheetService;
use App\Repositories\TimesheetRepository;
use App\Events\TimesheetCreated;
use App\Exceptions\TimesheetException;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
use Mockery;

class TimesheetServiceTest extends TestCase
{
    use RefreshDatabase;

    private TimesheetService $timesheetService;
    private TimesheetRepository $timesheetRepository;

    protected function setUp(): void
    {
        parent::setUp();

        $this->timesheetRepository = Mockery::mock(TimesheetRepository::class);
        $this->timesheetService = new TimesheetService($this->timesheetRepository);

        Event::fake();
    }

    /** @test */
    public function it_creates_timesheet_entry_successfully()
    {
        // Arrange
        $user = User::factory()->create();
        $project = Project::factory()->create();

        $timesheetData = [
            'project_id' => $project->id,
            'log_date' => '2024-01-15',
            'spent_hours' => 8.0,
            'description' => 'Development work',
            'is_billable' => true,
        ];

        $expectedTimesheet = new Timesheet([
            'user_id' => $user->id,
            ...$timesheetData
        ]);

        $this->timesheetRepository
            ->shouldReceive('create')
            ->once()
            ->with(array_merge(['user_id' => $user->id], $timesheetData))
            ->andReturn($expectedTimesheet);

        // Act
        $result = $this->timesheetService->createTimesheet($user, $timesheetData);

        // Assert
        $this->assertInstanceOf(Timesheet::class, $result);
        $this->assertEquals($project->id, $result->project_id);
        $this->assertEquals(8.0, $result->spent_hours);

        Event::assertDispatched(TimesheetCreated::class, function ($event) use ($expectedTimesheet) {
            return $event->timesheet->id === $expectedTimesheet->id;
        });
    }

    /** @test */
    public function it_validates_hours_do_not_exceed_daily_limit()
    {
        // Arrange
        $user = User::factory()->create();
        $project = Project::factory()->create();

        $timesheetData = [
            'project_id' => $project->id,
            'log_date' => '2024-01-15',
            'spent_hours' => 25.0, // Exceeds 24 hours
            'description' => 'Overtime work',
            'is_billable' => true,
        ];

        // Act & Assert
        $this->expectException(TimesheetException::class);
        $this->expectExceptionMessage('Hours cannot exceed 24 per day');

        $this->timesheetService->createTimesheet($user, $timesheetData);
    }

    /** @test */
    public function it_calculates_weekly_hours_correctly()
    {
        // Arrange
        $user = User::factory()->create();
        $startDate = Carbon::parse('2024-01-15');
        $endDate = Carbon::parse('2024-01-21');

        $timesheets = collect([
            new Timesheet(['spent_hours' => 8.0]),
            new Timesheet(['spent_hours' => 7.5]),
            new Timesheet(['spent_hours' => 8.0]),
            new Timesheet(['spent_hours' => 8.0]),
            new Timesheet(['spent_hours' => 6.0]),
        ]);

        $this->timesheetRepository
            ->shouldReceive('getUserTimesheetsByDateRange')
            ->once()
            ->with($user->id, $startDate, $endDate)
            ->andReturn($timesheets);

        // Act
        $totalHours = $this->timesheetService->getWeeklyHours($user, $startDate);

        // Assert
        $this->assertEquals(37.5, $totalHours);
    }

    /** @test */
    public function it_handles_timesheet_approval_workflow()
    {
        // Arrange
        $user = User::factory()->create();
        $approver = User::factory()->create(['role' => 'manager']);
        $timesheet = Timesheet::factory()->create([
            'user_id' => $user->id,
            'approval_status' => 'pending'
        ]);

        $this->timesheetRepository
            ->shouldReceive('find')
            ->once()
            ->with($timesheet->id)
            ->andReturn($timesheet);

        $this->timesheetRepository
            ->shouldReceive('update')
            ->once()
            ->with($timesheet->id, [
                'approval_status' => 'approved',
                'approved_by' => $approver->id,
                'approved_at' => Mockery::type(Carbon::class)
            ])
            ->andReturn($timesheet);

        // Act
        $result = $this->timesheetService->approveTimesheet($timesheet->id, $approver);

        // Assert
        $this->assertEquals('approved', $result->approval_status);
        $this->assertEquals($approver->id, $result->approved_by);
    }
}

Feature Testing

<?php

namespace Tests\Feature\Api;

use App\Models\User;
use App\Models\Project;
use App\Models\Timesheet;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class TimesheetApiTest extends TestCase
{
    use RefreshDatabase, WithFaker;

    /** @test */
    public function authenticated_user_can_create_timesheet_entry()
    {
        // Arrange
        $user = User::factory()->create();
        $project = Project::factory()->create();

        Sanctum::actingAs($user);

        $timesheetData = [
            'project_id' => $project->id,
            'log_date' => '2024-01-15',
            'spent_hours' => 8.0,
            'description' => 'Development work',
            'is_billable' => true,
        ];

        // Act
        $response = $this->postJson('/api/timesheets', $timesheetData);

        // Assert
        $response->assertStatus(201)
                ->assertJsonStructure([
                    'data' => [
                        'id',
                        'project_id',
                        'log_date',
                        'spent_hours',
                        'description',
                        'is_billable',
                        'approval_status'
                    ],
                    'message'
                ]);

        $this->assertDatabaseHas('timelogs', [
            'user_id' => $user->id,
            'project_id' => $project->id,
            'spent_hours' => 8.0,
        ]);
    }

    /** @test */
    public function user_cannot_create_timesheet_for_future_date()
    {
        // Arrange
        $user = User::factory()->create();
        $project = Project::factory()->create();

        Sanctum::actingAs($user);

        $futureDate = now()->addDays(5)->format('Y-m-d');

        $timesheetData = [
            'project_id' => $project->id,
            'log_date' => $futureDate,
            'spent_hours' => 8.0,
            'description' => 'Future work',
            'is_billable' => true,
        ];

        // Act
        $response = $this->postJson('/api/timesheets', $timesheetData);

        // Assert
        $response->assertStatus(422)
                ->assertJsonValidationErrors(['log_date']);
    }

    /** @test */
    public function user_can_retrieve_their_timesheets()
    {
        // Arrange
        $user = User::factory()->create();
        $project = Project::factory()->create();

        $timesheets = Timesheet::factory()->count(3)->create([
            'user_id' => $user->id,
            'project_id' => $project->id,
        ]);

        Sanctum::actingAs($user);

        // Act
        $response = $this->getJson('/api/timesheets');

        // Assert
        $response->assertStatus(200)
                ->assertJsonCount(3, 'data')
                ->assertJsonStructure([
                    'data' => [
                        '*' => [
                            'id',
                            'project_id',
                            'log_date',
                            'spent_hours',
                            'description',
                            'is_billable',
                            'approval_status'
                        ]
                    ],
                    'meta'
                ]);
    }

    /** @test */
    public function user_cannot_access_other_users_timesheets()
    {
        // Arrange
        $user1 = User::factory()->create();
        $user2 = User::factory()->create();
        $project = Project::factory()->create();

        $timesheet = Timesheet::factory()->create([
            'user_id' => $user2->id,
            'project_id' => $project->id,
        ]);

        Sanctum::actingAs($user1);

        // Act
        $response = $this->getJson("/api/timesheets/{$timesheet->id}");

        // Assert
        $response->assertStatus(403);
    }
}

JavaScript/AngularJS Testing

Unit Testing with Jasmine

// tests/unit/controllers/TimesheetControllerSpec.js

describe('TimesheetController', function() {
    var controller, scope, TimesheetService, NotificationService, $q;

    beforeEach(module('timesheet'));

    beforeEach(inject(function($controller, $rootScope, _$q_) {
        scope = $rootScope.$new();
        $q = _$q_;

        // Mock services
        TimesheetService = jasmine.createSpyObj('TimesheetService', [
            'getTimesheets',
            'createTimesheet',
            'updateTimesheet',
            'deleteTimesheet'
        ]);

        NotificationService = jasmine.createSpyObj('NotificationService', [
            'success',
            'error'
        ]);

        controller = $controller('TimesheetController', {
            $scope: scope,
            TimesheetService: TimesheetService,
            NotificationService: NotificationService
        });
    }));

    describe('initialization', function() {
        it('should initialize with default values', function() {
            expect(controller.timesheets).toEqual([]);
            expect(controller.selectedDate).toBeDefined();
            expect(controller.isLoading).toBe(false);
            expect(controller.errors).toEqual({});
        });

        it('should load timesheets on initialization', function() {
            var mockTimesheets = [
                { id: 1, project_id: 1, spent_hours: 8 },
                { id: 2, project_id: 2, spent_hours: 6 }
            ];

            TimesheetService.getTimesheets.and.returnValue(
                $q.resolve({ data: mockTimesheets })
            );

            controller.loadTimesheets();
            scope.$apply();

            expect(TimesheetService.getTimesheets).toHaveBeenCalled();
            expect(controller.timesheets).toEqual(mockTimesheets);
            expect(controller.isLoading).toBe(false);
        });
    });

    describe('saveTimesheet', function() {
        it('should save new timesheet successfully', function() {
            var newTimesheet = {
                project_id: 1,
                spent_hours: 8,
                description: 'Development work'
            };

            var savedTimesheet = { id: 1, ...newTimesheet };

            TimesheetService.createTimesheet.and.returnValue(
                $q.resolve({ data: savedTimesheet })
            );

            TimesheetService.getTimesheets.and.returnValue(
                $q.resolve({ data: [savedTimesheet] })
            );

            controller.saveTimesheet(newTimesheet);
            scope.$apply();

            expect(TimesheetService.createTimesheet).toHaveBeenCalledWith(newTimesheet);
            expect(NotificationService.success).toHaveBeenCalledWith('Timesheet saved successfully');
            expect(controller.timesheets).toEqual([savedTimesheet]);
        });

        it('should handle validation errors', function() {
            var invalidTimesheet = {
                project_id: null,
                spent_hours: 0
            };

            var result = controller.saveTimesheet(invalidTimesheet);

            expect(result).toBeDefined();
            expect(controller.errors.project).toBe('Project is required');
            expect(controller.errors.hours).toBe('Hours must be greater than 0');
        });

        it('should handle server errors', function() {
            var timesheet = {
                project_id: 1,
                spent_hours: 8
            };

            var errorResponse = {
                status: 422,
                data: {
                    errors: {
                        spent_hours: ['Hours exceed daily limit']
                    }
                }
            };

            TimesheetService.createTimesheet.and.returnValue(
                $q.reject(errorResponse)
            );

            controller.saveTimesheet(timesheet);
            scope.$apply();

            expect(controller.errors).toEqual(errorResponse.data.errors);
        });
    });

    describe('validation', function() {
        it('should validate required fields', function() {
            var timesheet = {};

            var isValid = controller.validateTimesheet(timesheet);

            expect(isValid).toBe(false);
            expect(controller.errors.project).toBe('Project is required');
            expect(controller.errors.hours).toBe('Hours must be greater than 0');
        });

        it('should validate hours range', function() {
            var timesheet = {
                project_id: 1,
                spent_hours: 25
            };

            var isValid = controller.validateTimesheet(timesheet);

            expect(isValid).toBe(false);
            expect(controller.errors.hours).toBe('Hours cannot exceed 24 per day');
        });

        it('should pass validation for valid timesheet', function() {
            var timesheet = {
                project_id: 1,
                spent_hours: 8
            };

            var isValid = controller.validateTimesheet(timesheet);

            expect(isValid).toBe(true);
            expect(Object.keys(controller.errors)).toEqual([]);
        });
    });
});

Service Testing

// tests/unit/services/TimesheetServiceSpec.js

describe('TimesheetService', function() {
    var TimesheetService, $httpBackend, ApiConfig;

    beforeEach(module('timesheet'));

    beforeEach(inject(function(_TimesheetService_, _$httpBackend_, _ApiConfig_) {
        TimesheetService = _TimesheetService_;
        $httpBackend = _$httpBackend_;
        ApiConfig = _ApiConfig_;
    }));

    afterEach(function() {
        $httpBackend.verifyNoOutstandingExpectation();
        $httpBackend.verifyNoOutstandingRequest();
    });

    describe('getTimesheets', function() {
        it('should fetch timesheets for date range', function() {
            var startDate = new Date('2024-01-15');
            var endDate = new Date('2024-01-21');
            var mockResponse = {
                data: [
                    { id: 1, spent_hours: 8 },
                    { id: 2, spent_hours: 6 }
                ]
            };

            $httpBackend
                .expectGET(ApiConfig.baseUrl + '/api/timesheets?start_date=2024-01-15&end_date=2024-01-21')
                .respond(200, mockResponse);

            var result;
            TimesheetService.getTimesheets(startDate, endDate)
                .then(function(response) {
                    result = response;
                });

            $httpBackend.flush();

            expect(result).toEqual(mockResponse);
        });

        it('should handle API errors', function() {
            var startDate = new Date('2024-01-15');

            $httpBackend
                .expectGET(ApiConfig.baseUrl + '/api/timesheets?start_date=2024-01-15&end_date=2024-01-15')
                .respond(500, { error: 'Server error' });

            var error;
            TimesheetService.getTimesheets(startDate)
                .catch(function(response) {
                    error = response;
                });

            $httpBackend.flush();

            expect(error.status).toBe(500);
        });
    });

    describe('createTimesheet', function() {
        it('should create new timesheet', function() {
            var timesheet = {
                project_id: 1,
                spent_hours: 8,
                description: 'Development work'
            };

            var mockResponse = {
                data: { id: 1, ...timesheet }
            };

            $httpBackend
                .expectPOST(ApiConfig.baseUrl + '/api/timesheets', timesheet)
                .respond(201, mockResponse);

            var result;
            TimesheetService.createTimesheet(timesheet)
                .then(function(response) {
                    result = response;
                });

            $httpBackend.flush();

            expect(result).toEqual(mockResponse);
        });
    });
});

End-to-End Testing

Cypress E2E Tests

// cypress/e2e/timesheet.cy.js

describe('Timesheet Management', () => {
    beforeEach(() => {
        // Login before each test
        cy.login('test@stratpoint.com', 'password');
        cy.visit('/timesheet');
    });

    it('should allow user to create timesheet entry', () => {
        // Select project
        cy.get('[data-cy=project-select]').click();
        cy.get('[data-cy=project-option]').first().click();

        // Enter hours
        cy.get('[data-cy=hours-input]').type('8');

        // Enter description
        cy.get('[data-cy=description-input]').type('Development work on user authentication');

        // Save timesheet
        cy.get('[data-cy=save-button]').click();

        // Verify success message
        cy.get('[data-cy=notification]').should('contain', 'Timesheet saved successfully');

        // Verify entry appears in list
        cy.get('[data-cy=timesheet-list]').should('contain', '8.00');
        cy.get('[data-cy=timesheet-list]').should('contain', 'Development work');
    });

    it('should validate required fields', () => {
        // Try to save without selecting project
        cy.get('[data-cy=save-button]').click();

        // Verify validation errors
        cy.get('[data-cy=project-error]').should('contain', 'Project is required');
        cy.get('[data-cy=hours-error]').should('contain', 'Hours must be greater than 0');
    });

    it('should allow editing existing timesheet', () => {
        // Create a timesheet first
        cy.createTimesheet({
            project_id: 1,
            spent_hours: 6,
            description: 'Initial work'
        });

        // Edit the timesheet
        cy.get('[data-cy=edit-button]').first().click();
        cy.get('[data-cy=hours-input]').clear().type('8');
        cy.get('[data-cy=description-input]').clear().type('Updated work description');
        cy.get('[data-cy=save-button]').click();

        // Verify changes
        cy.get('[data-cy=timesheet-list]').should('contain', '8.00');
        cy.get('[data-cy=timesheet-list]').should('contain', 'Updated work description');
    });

    it('should calculate weekly totals correctly', () => {
        // Create multiple timesheet entries
        const entries = [
            { hours: 8, description: 'Monday work' },
            { hours: 7.5, description: 'Tuesday work' },
            { hours: 8, description: 'Wednesday work' },
            { hours: 6, description: 'Thursday work' },
            { hours: 4, description: 'Friday work' }
        ];

        entries.forEach((entry, index) => {
            cy.get('[data-cy=project-select]').click();
            cy.get('[data-cy=project-option]').first().click();
            cy.get('[data-cy=hours-input]').type(entry.hours.toString());
            cy.get('[data-cy=description-input]').type(entry.description);
            cy.get('[data-cy=save-button]').click();

            if (index < entries.length - 1) {
                cy.get('[data-cy=next-day-button]').click();
            }
        });

        // Verify weekly total
        cy.get('[data-cy=weekly-total]').should('contain', '33.5');
    });
});

Performance Testing

Load Testing with Artillery

# tests/performance/load-test.yml
config:
  target: 'https://timesheet.stratpoint.com'
  phases:
    - duration: 60
      arrivalRate: 5
      name: "Warm up"
    - duration: 120
      arrivalRate: 10
      name: "Ramp up load"
    - duration: 300
      arrivalRate: 20
      name: "Sustained load"
  payload:
    path: "users.csv"
    fields:
      - "email"
      - "password"

scenarios:
  - name: "User login and timesheet operations"
    weight: 70
    flow:
      - post:
          url: "/api/auth/login"
          json:
            email: "{{ email }}"
            password: "{{ password }}"
          capture:
            - json: "$.token"
              as: "authToken"
      - get:
          url: "/api/timesheets"
          headers:
            Authorization: "Bearer {{ authToken }}"
      - post:
          url: "/api/timesheets"
          headers:
            Authorization: "Bearer {{ authToken }}"
          json:
            project_id: 1
            log_date: "2024-01-15"
            spent_hours: 8
            description: "Load test entry"
            is_billable: true

  - name: "Dashboard access"
    weight: 30
    flow:
      - post:
          url: "/api/auth/login"
          json:
            email: "{{ email }}"
            password: "{{ password }}"
          capture:
            - json: "$.token"
              as: "authToken"
      - get:
          url: "/api/dashboard"
          headers:
            Authorization: "Bearer {{ authToken }}"

Test Automation

CI/CD Pipeline Testing

# .github/workflows/test.yml
name: Test Suite

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  unit-tests:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: timesheet_test
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3

    steps:
    - uses: actions/checkout@v3

    - name: Setup PHP
      uses: shivammathur/setup-php@v2
      with:
        php-version: '8.1'
        extensions: mbstring, xml, ctype, iconv, intl, pdo_mysql

    - name: Install dependencies
      run: composer install --prefer-dist --no-progress

    - name: Run tests
      run: |
        php artisan test --coverage --min=80
        php artisan test --testsuite=Feature

    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

  frontend-tests:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3

    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'

    - name: Install dependencies
      run: npm ci

    - name: Run unit tests
      run: npm run test:unit

    - name: Run E2E tests
      run: npm run test:e2e

  security-tests:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3

    - name: Run security scan
      run: |
        composer audit
        npm audit

    - name: OWASP ZAP Scan
      uses: zaproxy/action-full-scan@v0.4.0
      with:
        target: 'https://staging.timesheet.stratpoint.com'

This comprehensive testing guide ensures robust quality assurance for the Stratpoint Timesheet Application across all layers of the application stack.