---
title: "Unit testing"
manual: "LLM Foundation"
version: "0.41"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:testing-unit-testing@0.41"
source: "Testing/UnitTesting.rst"
rendered: "2026-10-20T08:48:47+00:00"
---

# Unit testing {#testing-unit-testing}

## Running tests {#testing-running}

### Prerequisites {#testing-prerequisites}

**Install development dependencies**

```bash
# Install dependencies (dev deps included by default)
composer install
```

### Unit tests {#testing-unit}

**Run unit tests**

```bash
# Recommended: Use runTests.sh (Docker-based, consistent environment)
Build/Scripts/runTests.sh -s unit

# With specific PHP version
Build/Scripts/runTests.sh -s unit -p 8.3

# Alternative: Via Composer script
composer ci:test:php:unit
```

### Integration tests {#testing-integration}

**Run integration tests**

```bash
# Integration tests use mocked HTTP and need no provider credentials
Build/Scripts/runTests.sh -s integration
```

### All tests {#testing-all}

**Run the PHP test suites and quality checks**

```bash
# Run the PHP test suites and architecture rules
Build/Scripts/runTests.sh -s unit
Build/Scripts/runTests.sh -s integration
Build/Scripts/runTests.sh -s fuzzy
Build/Scripts/runTests.sh -s architecture
Build/Scripts/runTests.sh -s functional -d sqlite

# Run code quality checks
Build/Scripts/runTests.sh -s cgl -n
Build/Scripts/runTests.sh -s phpstan
Build/Scripts/runTests.sh -s rector -n -p 8.2
```

The functional suite also discovers backend workflow and TCA suites from
`Build/FunctionalTests.xml`. Some functional provider smoke tests make
network requests; inspect their prerequisites before running the whole suite.
Browser workflows require a running TYPO3 test instance; see
[E2E testing](https://docs.typo3.org/permalink/netresearch/nr-llm:testing-e2e@0.41). The Python sidecars have separate unittest suites under
`Build/decision` and `Build/reranker`.

## Test structure {#testing-structure}

**Test directory structure**

```text
Tests/
├── Unit/
│   ├── Domain/
│   │   └── Model/
│   │       ├── CompletionResponseTest.php
│   │       ├── EmbeddingResponseTest.php
│   │       └── UsageStatisticsTest.php
│   ├── Provider/
│   │   ├── OpenAiProviderTest.php
│   │   ├── ClaudeProviderTest.php
│   │   ├── GeminiProviderTest.php
│   │   └── AbstractProviderTest.php
│   └── Service/
│       ├── LlmServiceManagerTest.php
│       └── Feature/
│           ├── CompletionServiceTest.php
│           ├── EmbeddingServiceTest.php
│           ├── VisionServiceTest.php
│           └── TranslationServiceTest.php
├── Integration/
│   ├── Provider/
│   │   └── ProviderIntegrationTest.php
│   └── Service/
│       └── ServiceIntegrationTest.php
├── Functional/
│   ├── Controller/
│   │   └── BackendControllerTest.php
│   └── Repository/
│       └── ProviderRepositoryTest.php
└── E2E/
    └── WorkflowTest.php
```

## Writing tests {#testing-writing}

### Unit test example {#testing-unit-example}

**Example: Unit test**

```php
namespace Your\Extension\Tests\Unit;

use Netresearch\NrLlm\Domain\Model\CompletionResponse;
use Netresearch\NrLlm\Domain\Model\UsageStatistics;
use Netresearch\NrLlm\Service\Feature\CompletionServiceInterface;
use Netresearch\NrLlm\Testing\FakeCompletionService;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class SummaryService
{
    public function __construct(
        private readonly CompletionServiceInterface $completion,
    ) {}

    public function summarize(string $text): string
    {
        return $this->completion->complete('Summarize: ' . $text)->content;
    }
}

final class SummaryServiceTest extends TestCase
{
    #[Test]
    public function returnsTheSummaryAndSendsTheInput(): void
    {
        $completion = new FakeCompletionService();
        $completion->responses[] = new CompletionResponse(
            content: 'A short summary.',
            model: 'test-model',
            usage: new UsageStatistics(10, 5, 15),
        );
        $subject = new SummaryService($completion);

        self::assertSame('A short summary.', $subject->summarize('Long text'));
        self::assertSame(
            [['prompt' => 'Summarize: Long text', 'options' => null]],
            $completion->completeCalls,
        );
    }
}
```

## Mocking providers {#testing-mocking}

### Using mock provider {#testing-mock-provider}

**Example: Mock provider**

```php
use Netresearch\NrLlm\Domain\Model\CompletionResponse;
use Netresearch\NrLlm\Domain\Model\UsageStatistics;
use Netresearch\NrLlm\Provider\Contract\ProviderInterface;

$mockProvider = $this->createMock(ProviderInterface::class);
$mockProvider
    ->method('chatCompletion')
    ->willReturn(new CompletionResponse(
        content: 'Mocked response',
        model: 'mock-model',
        usage: new UsageStatistics(100, 50, 150),
        finishReason: 'stop',
        provider: 'mock'
    ));
$mockProvider->method('isConfigured')->willReturn(true);
```

### Using HTTP mock {#testing-http-mock}

**Example: HTTP mock**

```php
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;

$mock = new MockHandler([
    new Response(200, [], json_encode([
        'choices' => [
            [
                'message' => ['content' => 'Test response'],
                'finish_reason' => 'stop',
            ],
        ],
        'model' => 'gpt-5',
        'usage' => [
            'prompt_tokens' => 10,
            'completion_tokens' => 5,
            'total_tokens' => 15,
        ],
    ])),
]);

$handlerStack = HandlerStack::create($mock);
$client = new Client(['handler' => $handlerStack]);

// On an adapter constructed with its normal dependencies and configured
// by the manager, replace the transport after registerProvider():
$provider->setHttpClient($client);
```
