Testing Guide

This guide provides comprehensive documentation for testing the WooAI Chatbot Pro plugin across all layers: PHP unit and integration tests, JavaScript component tests, and end-to-end flows.

1. Testing Overview

Testing Philosophy

The WooAI Chatbot Pro testing strategy follows these core principles:

  1. Test Isolation: Each test runs independently with clean state. Global mocks and transients are reset between tests to prevent cross-contamination.

  2. Mock External Dependencies: AI providers, WooCommerce, and external APIs are mocked to ensure deterministic behavior and fast execution without network calls.

  3. Hierarchical Coverage: Unit tests verify individual components, integration tests verify component interactions, and E2E tests verify complete user flows.

  4. Fail Fast: Tests are configured with strict mode (failOnRisky, failOnWarning) to catch potential issues early.

Test Types

Type Framework Directory Purpose
Unit PHPUnit tests/AI, tests/Search, tests/Chat Individual class/method testing
Integration PHPUnit tests/Integration Component interaction testing
E2E PHPUnit tests/E2E Complete flow testing
JavaScript Jest assets/src/**/__tests__ React component and hook testing

Coverage Goals

The project enforces strict coverage thresholds:

// jest.config.js
coverageThreshold: {
  global: {
    lines: 85,
    branches: 85,
    functions: 85,
    statements: 85,
  },
},

PHP coverage targets: 80%+ line coverage for core modules.


2. PHP Testing (PHPUnit)

Setup

PHPUnit Configuration

The phpunit.xml configures three test suites with strict execution:

<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         bootstrap="tests/bootstrap.php"
         colors="true"
         executionOrder="depends,defects"
         beStrictAboutOutputDuringTests="true"
         failOnRisky="true"
         failOnWarning="true">

    <testsuites>
        <testsuite name="Unit">
            <directory>tests/AI</directory>
            <directory>tests/Search</directory>
            <directory>tests/Chat</directory>
        </testsuite>
        <testsuite name="Integration">
            <directory>tests/Integration</directory>
        </testsuite>
        <testsuite name="E2E">
            <directory>tests/E2E</directory>
        </testsuite>
    </testsuites>
</phpunit>

Test Database Setup

Tests use mocked WordPress functions defined in tests/bootstrap.php. No actual database connection is required for unit tests:

// Global mock storage
global $_wp_options, $_wp_transients;
$_wp_options    = array();
$_wp_transients = array();

Environment variables for AI providers are set in the PHPUnit configuration:

<php>
    <env name="GEMINI_API_KEY" value="test-key"/>
    <env name="OPENAI_API_KEY" value="test-key"/>
    <env name="ANTHROPIC_API_KEY" value="test-key"/>
    <env name="SUPABASE_URL" value="https://test.supabase.co"/>
    <env name="SUPABASE_KEY" value="test-key"/>
</php>

Test Structure

Test File Organization

tests/
├── bootstrap.php              # PHPUnit bootstrap with WordPress mocks
├── Helpers/
│   ├── TestCase.php           # Base test class with utilities
│   ├── MockAIProvider.php     # AI provider test double
│   └── MockWooCommerce.php    # WooCommerce simulation
├── AI/
│   └── ProvidersTest.php      # AI provider unit tests
├── Search/
│   └── SemanticSearchTest.php # Semantic search tests
├── Chat/
│   └── MessageHandlerTest.php # Message handling tests
├── Integration/
│   └── WooCommerceTest.php    # WooCommerce integration tests
└── E2E/
    └── ChatFlowTest.php       # End-to-end flow tests

Test Naming Conventions

Follow this pattern for test method names:

public function test_{method_or_feature}_{scenario}_{expected_outcome}()

Examples:

Base Test Classes

Extend WooAIChatbot\Tests\Helpers\TestCase for access to common utilities:

namespace WooAIChatbot\Tests\AI;

use WooAIChatbot\Tests\Helpers\TestCase;

class ProvidersTest extends TestCase {

    protected function setUp(): void {
        parent::setUp();
        // Test-specific setup
    }

    protected function tearDown(): void {
        parent::tearDown();
        // Cleanup
    }
}

Available helper methods in TestCase:

Method Purpose
setEnv($name, $value) Set environment variable for test
createWPError($code, $message) Create mock WP_Error
assertIsWPError($actual) Assert value is WP_Error
assertNotWPError($actual) Assert value is not WP_Error
assertWPErrorCode($code, $error) Assert specific error code
assertArrayHasKeys($keys, $array) Assert array has all keys
getPrivateProperty($obj, $prop) Access private property
callPrivateMethod($obj, $method) Call private method
createTempFile($content) Create temporary file

Running Tests

Composer Test Commands

# Run all tests
composer test

# Run specific test suite
composer test:unit
composer test:integration
composer test:e2e

# Run with coverage
composer test:coverage          # HTML report
composer test:coverage:text     # Console output

Individual Test Execution

# Run specific test file
./vendor/bin/phpunit tests/AI/ProvidersTest.php

# Run specific test method
./vendor/bin/phpunit --filter test_gemini_provider_initialization

# Run by group annotation
./vendor/bin/phpunit --group ai
./vendor/bin/phpunit --group woocommerce

Code Coverage

Coverage reports are generated to coverage/:

composer test:coverage

Output locations:

Writing Tests

Unit Test Example

/**
 * Test GeminiProvider initialization
 *
 * @covers \WooAIChatbot\AI\Providers\GeminiProvider::__construct
 * @group ai
 * @group providers
 */
public function test_gemini_provider_initialization() {
    // Arrange
    $this->setEnv( 'GEMINI_API_KEY', 'test-gemini-key' );

    // Act
    $provider = new GeminiProvider();

    // Assert
    $this->assertInstanceOf( GeminiProvider::class, $provider );
    $this->assertTrue( $provider->is_available() );
    $this->assertEquals( 'Google Gemini', $provider->get_provider_name() );
}

Integration Test Example

/**
 * Test product context extraction
 *
 * @group integration
 * @group woocommerce
 */
public function test_product_context_extraction() {
    // Arrange
    $product = MockWooCommerce::create_product(
        array(
            'id'          => 123,
            'name'        => 'Premium Headphones',
            'price'       => 199.99,
            'description' => 'High-quality wireless headphones',
            'in_stock'    => true,
        )
    );

    // Act
    $context = array(
        'product_id'  => $product->get_id(),
        'name'        => $product->get_name(),
        'price'       => $product->get_price(),
        'description' => $product->get_description(),
        'in_stock'    => $product->is_in_stock(),
    );

    // Assert
    $this->assertArrayHasKeys(
        array( 'product_id', 'name', 'price', 'description', 'in_stock' ),
        $context
    );
    $this->assertEquals( 'Premium Headphones', $context['name'] );
    $this->assertTrue( $context['in_stock'] );
}

Mocking Strategies

MockAIProvider Usage:

// Success scenario
$mock_provider = new MockAIProvider(
    array(
        'response' => array(
            'content' => 'Test response',
            'tokens'  => 5,
            'model'   => 'test-model',
        ),
    )
);

$response = $mock_provider->generate_response( $messages );
$this->assertEquals( 'Test response', $response['content'] );

// Error scenario
$mock_provider = new MockAIProvider(
    array(
        'response' => $this->createWPError( 'rate_limit_exceeded', 'Rate limit exceeded' ),
    )
);

$response = $mock_provider->generate_response( array() );
$this->assertIsWPError( $response );

// Call verification
$this->assertTrue( $mock_provider->was_called( 'generate_response' ) );
$this->assertEquals( 1, $mock_provider->get_call_count( 'generate_response' ) );

MockWooCommerce Usage:

// Create test products
MockWooCommerce::create_product( array(
    'id'    => 1,
    'name'  => 'Product 1',
    'price' => 29.99,
));

// Add to cart
MockWooCommerce::add_to_cart( 1, 2 );

// Get cart
$cart = MockWooCommerce::get_cart();

// Reset between tests
MockWooCommerce::reset();

3. JavaScript Testing (Jest)

Setup

Jest Configuration

The jest.config.js configures TypeScript support with path aliases:

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  roots: ['<rootDir>/assets/src'],
  testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/assets/src/$1',
    '^@admin/(.*)$': '<rootDir>/assets/src/admin/$1',
    '^@chat/(.*)$': '<rootDir>/assets/src/chat/$1',
    '^@components/(.*)$': '<rootDir>/assets/src/components/$1',
    '^@hooks/(.*)$': '<rootDir>/assets/src/hooks/$1',
    '^@utils/(.*)$': '<rootDir>/assets/src/utils/$1',
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
  },
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};

Testing Library Integration

The jest.setup.js provides WordPress globals and configuration:

import '@testing-library/jest-dom';

// Mock WordPress global
global.wp = {
  i18n: {
    __: (text) => text,
    _x: (text) => text,
    _n: (single, plural, number) => (number === 1 ? single : plural),
  },
  hooks: {
    addAction: jest.fn(),
    addFilter: jest.fn(),
    doAction: jest.fn(),
    applyFilters: jest.fn(),
  },
};

// Mock window.wooAIConfig
global.window.wooAIConfig = {
  apiUrl: 'http://localhost/wp-json/woo-ai/v1',
  nonce: 'test-nonce',
  locale: 'en_US',
  isRtl: false,
};

Running Tests

# Run all JavaScript tests
npm run test

# Watch mode for development
npm run test:watch

# Generate coverage report
npm run test:coverage

Writing Tests

Component Test Example

import { render, screen, fireEvent } from '@testing-library/react';
import { ChatMessage } from '@components/ChatMessage';

describe('ChatMessage', () => {
  it('renders user message correctly', () => {
    // Arrange
    const message = {
      id: '1',
      role: 'user' as const,
      content: 'Hello, I need help with shoes',
      timestamp: new Date().toISOString(),
    };

    // Act
    render(<ChatMessage message={message} />);

    // Assert
    expect(screen.getByText('Hello, I need help with shoes')).toBeInTheDocument();
    expect(screen.getByRole('article')).toHaveClass('user-message');
  });

  it('renders assistant message with products', () => {
    // Arrange
    const message = {
      id: '2',
      role: 'assistant' as const,
      content: 'Here are some shoes I found',
      data: {
        products: [
          { id: 1, name: 'Running Shoes', price: '$99.00' },
        ],
      },
      timestamp: new Date().toISOString(),
    };

    // Act
    render(<ChatMessage message={message} />);

    // Assert
    expect(screen.getByText('Running Shoes')).toBeInTheDocument();
    expect(screen.getByText('$99.00')).toBeInTheDocument();
  });
});

Hook Test Example

import { renderHook, act } from '@testing-library/react';
import { useChat } from '@hooks/useChat';

describe('useChat', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  it('initializes with empty messages', () => {
    const { result } = renderHook(() => useChat());

    expect(result.current.messages).toEqual([]);
    expect(result.current.isLoading).toBe(false);
  });

  it('sends message and receives response', async () => {
    // Mock API response
    (global.fetch as jest.Mock).mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({
        content: 'Here are your results',
        type: 'text',
      }),
    });

    const { result } = renderHook(() => useChat());

    await act(async () => {
      await result.current.sendMessage('Show me laptops');
    });

    expect(result.current.messages).toHaveLength(2);
    expect(result.current.messages[0].role).toBe('user');
    expect(result.current.messages[1].role).toBe('assistant');
  });
});

Service Test Example

import { ChatService } from '@utils/ChatService';

describe('ChatService', () => {
  let chatService: ChatService;

  beforeEach(() => {
    chatService = new ChatService({
      apiUrl: 'http://localhost/wp-json/woo-ai/v1',
      nonce: 'test-nonce',
    });
    global.fetch = jest.fn();
  });

  it('sends chat message with correct headers', async () => {
    (global.fetch as jest.Mock).mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({ content: 'Response' }),
    });

    await chatService.send('Hello');

    expect(global.fetch).toHaveBeenCalledWith(
      'http://localhost/wp-json/woo-ai/v1/chat',
      expect.objectContaining({
        method: 'POST',
        headers: expect.objectContaining({
          'Content-Type': 'application/json',
          'X-WP-Nonce': 'test-nonce',
        }),
      })
    );
  });

  it('handles API errors gracefully', async () => {
    (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));

    await expect(chatService.send('Hello')).rejects.toThrow('Network error');
  });
});

4. E2E Testing (Playwright)

Setup

Playwright is included as a dependency for browser-based E2E testing:

# Install Playwright browsers
npx playwright install

Playwright Configuration

Create playwright.config.ts for WordPress E2E scenarios:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  timeout: 30000,
  use: {
    baseURL: 'http://localhost:8080',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { browserName: 'chromium' } },
    { name: 'firefox', use: { browserName: 'firefox' } },
    { name: 'webkit', use: { browserName: 'webkit' } },
  ],
});

Writing E2E Tests

Page Object Pattern

// e2e/pages/ChatWidgetPage.ts
import { Page, Locator } from '@playwright/test';

export class ChatWidgetPage {
  readonly page: Page;
  readonly chatButton: Locator;
  readonly chatInput: Locator;
  readonly sendButton: Locator;
  readonly messagesContainer: Locator;

  constructor(page: Page) {
    this.page = page;
    this.chatButton = page.locator('[data-testid="chat-widget-button"]');
    this.chatInput = page.locator('[data-testid="chat-input"]');
    this.sendButton = page.locator('[data-testid="send-button"]');
    this.messagesContainer = page.locator('[data-testid="messages-container"]');
  }

  async openChat() {
    await this.chatButton.click();
    await this.page.waitForSelector('[data-testid="chat-input"]');
  }

  async sendMessage(message: string) {
    await this.chatInput.fill(message);
    await this.sendButton.click();
  }

  async waitForResponse() {
    await this.page.waitForSelector('[data-testid="assistant-message"]');
  }

  async getLastMessage(): Promise<string> {
    const messages = this.messagesContainer.locator('[data-testid="message"]');
    const lastMessage = messages.last();
    return lastMessage.textContent() ?? '';
  }
}

Common Scenarios

// e2e/chat-flow.spec.ts
import { test, expect } from '@playwright/test';
import { ChatWidgetPage } from './pages/ChatWidgetPage';

test.describe('Chat Widget Flow', () => {
  let chatWidget: ChatWidgetPage;

  test.beforeEach(async ({ page }) => {
    chatWidget = new ChatWidgetPage(page);
    await page.goto('/shop');
  });

  test('opens chat widget and sends message', async ({ page }) => {
    await chatWidget.openChat();
    await chatWidget.sendMessage('Show me running shoes');
    await chatWidget.waitForResponse();

    const response = await chatWidget.getLastMessage();
    expect(response).toContain('running shoes');
  });

  test('displays product cards in response', async ({ page }) => {
    await chatWidget.openChat();
    await chatWidget.sendMessage('I need a laptop under $1000');
    await chatWidget.waitForResponse();

    const productCards = page.locator('[data-testid="product-card"]');
    await expect(productCards).toHaveCount.greaterThan(0);
  });
});

5. Code Quality Tools

ESLint

Configuration

The .eslintrc.json extends recommended TypeScript and React rules:

{
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:react-hooks/recommended",
    "plugin:@typescript-eslint/recommended",
    "prettier"
  ],
  "rules": {
    "react/react-in-jsx-scope": "off",
    "@typescript-eslint/no-explicit-any": "warn",
    "@typescript-eslint/no-unused-vars": ["error", {
      "argsIgnorePattern": "^_",
      "varsIgnorePattern": "^_"
    }],
    "no-console": ["warn", { "allow": ["warn", "error"] }]
  }
}

Commands

# Lint with auto-fix
npm run lint

# Check only (no modifications)
npm run lint:check

PHPCS

WordPress Standards

The phpcs.xml configuration enforces PHP syntax and security rules:

<ruleset name="WooAI Chatbot Pro">
    <file>./woo-ai-chatbot-pro.php</file>
    <file>./includes</file>

    <exclude-pattern>*/vendor/*</exclude-pattern>
    <exclude-pattern>*/tests/*</exclude-pattern>

    <!-- Syntax and security -->
    <rule ref="Generic.PHP.Syntax"/>
    <rule ref="Squiz.PHP.Eval"/>
    <rule ref="Generic.PHP.NoSilencedErrors"/>
</ruleset>

Commands

# Run PHPCS
npm run phpcs
# or
composer phpcs

# Auto-fix issues
npm run phpcs:fix
# or
composer phpcbf

TypeScript

Strict Mode Configuration

The tsconfig.json enables all strict checking options:

{
  "compilerOptions": {
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictPropertyInitialization": true
  }
}

Type Checking

# Run type check
npm run typecheck

6. Pre-commit Hooks (Husky)

Hook Configuration

Install Husky hooks:

npm run prepare

This runs husky install and sets up Git hooks in .husky/.

Lint-staged Setup

Add to package.json:

{
  "lint-staged": {
    "assets/src/**/*.{ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "includes/**/*.php": [
      "composer phpcs"
    ]
  }
}

Pre-commit Hook

Create .husky/pre-commit:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npm run typecheck
npx lint-staged
npm run test -- --passWithNoTests

7. Test Data & Fixtures

Sample Data

Use MockWooCommerce to create consistent test products:

protected function createTestProducts(): array {
    return array(
        MockWooCommerce::create_product( array(
            'id'    => 1,
            'name'  => 'Running Shoes',
            'price' => 129.99,
        )),
        MockWooCommerce::create_product( array(
            'id'    => 2,
            'name'  => 'Hiking Boots',
            'price' => 189.99,
        )),
    );
}

Factory Patterns

class ProductFactory {
    private static int $id = 0;

    public static function create( array $overrides = array() ): MockProduct {
        return MockWooCommerce::create_product( array_merge(
            array(
                'id'    => ++self::$id,
                'name'  => 'Product ' . self::$id,
                'price' => rand( 10, 500 ) + 0.99,
            ),
            $overrides
        ));
    }

    public static function createMany( int $count ): array {
        return array_map( fn() => self::create(), range( 1, $count ) );
    }
}

Database Seeding

For integration tests requiring database state:

protected function seedTestData(): void {
    global $_wp_options;

    $_wp_options['woo_ai_settings'] = array(
        'primary_provider'   => 'gemini',
        'fallback_providers' => array( 'openai', 'claude' ),
        'temperature'        => 0.7,
    );

    // Create test products
    for ( $i = 1; $i <= 10; ++$i ) {
        MockWooCommerce::create_product( array(
            'id'    => $i,
            'name'  => "Test Product {$i}",
            'price' => $i * 10.00,
        ));
    }
}

8. CI/CD Integration

GitHub Actions

Create .github/workflows/tests.yml:

name: Tests

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

jobs:
  php-tests:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: ['8.1', '8.2', '8.3']

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          coverage: xdebug

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

      - name: Run PHPUnit
        run: composer test:coverage:text

      - name: Run PHPCS
        run: composer phpcs

  js-tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Type check
        run: npm run typecheck

      - name: Lint
        run: npm run lint:check

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

      - name: Build
        run: npm run build

Deployment Gates

Tests must pass before deployment. Configure branch protection rules:

  1. Required checks: php-tests, js-tests
  2. Coverage thresholds: Fail if coverage drops below 80%
  3. No force pushes: Protect main branch

Coverage Reporting

Upload coverage to external services:

- name: Upload coverage to Codecov
  uses: codecov/codecov-action@v3
  with:
    files: ./coverage/clover.xml
    fail_ci_if_error: true

Quick Reference

Task Command
Run all PHP tests composer test
Run PHP unit tests composer test:unit
Run PHP integration tests composer test:integration
Run PHP E2E tests composer test:e2e
PHP coverage report composer test:coverage
Run all JS tests npm run test
JS watch mode npm run test:watch
JS coverage npm run test:coverage
TypeScript check npm run typecheck
ESLint npm run lint
PHPCS npm run phpcs
Fix PHPCS issues npm run phpcs:fix

Troubleshooting

Common Issues

PHPUnit bootstrap fails:

Jest module resolution errors:

Coverage below threshold:

Mock not resetting between tests: