Skip to content

Commit 6ad1789

Browse files
authored
Test utilities (#54)
1 parent b1c8465 commit 6ad1789

File tree

7 files changed

+343
-1
lines changed

7 files changed

+343
-1
lines changed

Diff for: docs/.vitepress/config.mts

+4
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ export default defineConfig({
102102
text: "Prism Server",
103103
link: "/core-concepts/prism-server",
104104
},
105+
{
106+
text: "Testing",
107+
link: "/core-concepts/testing",
108+
},
105109
],
106110
},
107111
{

Diff for: docs/core-concepts/testing.md

+162
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# Testing
2+
3+
Want to make sure your Prism integrations work flawlessly? Let's dive into testing! Prism provides a powerful fake implementation that makes it a breeze to test your AI-powered features.
4+
5+
## Basic Test Setup
6+
7+
First, let's look at how to set up basic response faking:
8+
9+
```php
10+
use EchoLabs\Prism\Facades\Prism;
11+
use EchoLabs\Prism\ValueObjects\Usage;
12+
use EchoLabs\Prism\Enums\FinishReason;
13+
use EchoLabs\Prism\Providers\ProviderResponse;
14+
15+
public function test_can_generate_text(): void
16+
{
17+
// Create a fake provider response
18+
$fakeResponse = new ProviderResponse(
19+
text: 'Hello, I am Claude!',
20+
toolCalls: [],
21+
usage: new Usage(10, 20),
22+
finishReason: FinishReason::Stop,
23+
response: ['id' => 'fake-1', 'model' => 'fake-model']
24+
);
25+
26+
// Set up the fake
27+
$fake = Prism::fake([$fakeResponse]);
28+
29+
// Run your code
30+
$response = Prism::text()
31+
->using('anthropic', 'claude-3-sonnet')
32+
->withPrompt('Who are you?')
33+
->generate();
34+
35+
// Make assertions
36+
$this->assertEquals('Hello, I am Claude!', $response->text);
37+
}
38+
```
39+
40+
## Testing Multiple Responses
41+
42+
When testing conversations or tool usage, you might need to simulate multiple responses:
43+
44+
```php
45+
public function test_can_handle_tool_calls(): void
46+
{
47+
$responses = [
48+
new ProviderResponse(
49+
text: '',
50+
toolCalls: [
51+
new ToolCall(
52+
id: 'call_1',
53+
name: 'search',
54+
arguments: ['query' => 'Latest news']
55+
)
56+
],
57+
usage: new Usage(15, 25),
58+
finishReason: FinishReason::ToolCalls,
59+
response: ['id' => 'fake-1', 'model' => 'fake-model']
60+
),
61+
new ProviderResponse(
62+
text: 'Here are the latest news...',
63+
toolCalls: [],
64+
usage: new Usage(20, 30),
65+
finishReason: FinishReason::Stop,
66+
response: ['id' => 'fake-2', 'model' => 'fake-model']
67+
),
68+
];
69+
70+
$fake = Prism::fake($responses);
71+
}
72+
```
73+
74+
## Assertions
75+
76+
Prism's fake implementation provides several helpful assertion methods:
77+
78+
```php
79+
// Assert specific prompt was sent
80+
$fake->assertPrompt('Who are you?');
81+
82+
// Assert number of calls made
83+
$fake->assertCallCount(2);
84+
85+
// Assert detailed request properties
86+
$fake->assertRequest(function ($requests) {
87+
$this->assertEquals('anthropic', $requests[0]->provider);
88+
$this->assertEquals('claude-3-sonnet', $requests[0]->model);
89+
});
90+
```
91+
92+
## Testing Tools
93+
94+
When testing tools, you'll want to verify both the tool calls and their results. Here's a complete example:
95+
96+
```php
97+
public function test_can_use_weather_tool(): void
98+
{
99+
// Define the expected tool call and response sequence
100+
$responses = [
101+
// First response: AI decides to use the weather tool
102+
new ProviderResponse(
103+
text: '', // Empty text since the AI is using a tool
104+
toolCalls: [
105+
new ToolCall(
106+
id: 'call_123',
107+
name: 'weather',
108+
arguments: ['city' => 'Paris']
109+
)
110+
],
111+
usage: new Usage(15, 25),
112+
finishReason: FinishReason::ToolCalls,
113+
response: ['id' => 'fake-1', 'model' => 'fake-model']
114+
),
115+
// Second response: AI uses the tool result to form a response
116+
new ProviderResponse(
117+
text: 'Based on current conditions, the weather in Paris is sunny with a temperature of 72°F.',
118+
toolCalls: [],
119+
usage: new Usage(20, 30),
120+
finishReason: FinishReason::Stop,
121+
response: ['id' => 'fake-2', 'model' => 'fake-model']
122+
),
123+
];
124+
125+
// Set up the fake
126+
$fake = Prism::fake($responses);
127+
128+
// Create the weather tool
129+
$weatherTool = Tool::as('weather')
130+
->for('Get weather information')
131+
->withStringParameter('city', 'City name')
132+
->using(fn (string $city) => "The weather in {$city} is sunny with a temperature of 72°F");
133+
134+
// Run the actual test
135+
$response = Prism::text()
136+
->using('anthropic', 'claude-3-sonnet')
137+
->withPrompt('What\'s the weather in Paris?')
138+
->withTools([$weatherTool])
139+
->generate();
140+
141+
// Assert the correct number of API calls were made
142+
$fake->assertCallCount(2);
143+
144+
// Assert tool calls were made correctly
145+
$this->assertCount(1, $response->steps[0]->toolCalls);
146+
$this->assertEquals('weather', $response->steps[0]->toolCalls[0]->name);
147+
$this->assertEquals(['city' => 'Paris'], $response->steps[0]->toolCalls[0]->arguments());
148+
149+
// Assert tool results were processed
150+
$this->assertCount(1, $response->toolResults);
151+
$this->assertEquals(
152+
'The weather in Paris is sunny with a temperature of 72°F',
153+
$response->toolResults[0]->result
154+
);
155+
156+
// Assert final response
157+
$this->assertEquals(
158+
'Based on current conditions, the weather in Paris is sunny with a temperature of 72°F.',
159+
$response->text
160+
);
161+
}
162+
```

Diff for: src/Concerns/BuildsTextRequests.php

+1
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ protected function textRequest(): TextRequest
151151
return new TextRequest(
152152
model: $this->model,
153153
systemPrompt: $this->systemPrompt,
154+
prompt: $this->prompt,
154155
messages: $this->state->messages()->toArray(),
155156
temperature: $this->temperature,
156157
maxTokens: $this->maxTokens,

Diff for: src/Prism.php

+24-1
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,34 @@
55
namespace EchoLabs\Prism;
66

77
use EchoLabs\Prism\Contracts\Provider;
8+
use EchoLabs\Prism\Enums\Provider as ProviderEnum;
89
use EchoLabs\Prism\Generators\TextGenerator;
10+
use EchoLabs\Prism\Providers\ProviderResponse;
11+
use EchoLabs\Prism\Testing\PrismFake;
912

1013
class Prism
1114
{
12-
protected Provider $provider;
15+
/**
16+
* @param array<int, ProviderResponse> $responses
17+
*/
18+
public static function fake(array $responses = []): PrismFake
19+
{
20+
$fake = new PrismFake($responses);
21+
22+
app()->instance(PrismManager::class, new class($fake) extends PrismManager
23+
{
24+
public function __construct(
25+
private readonly PrismFake $fake
26+
) {}
27+
28+
public function resolve(ProviderEnum|string $name): Provider
29+
{
30+
return $this->fake;
31+
}
32+
});
33+
34+
return $fake;
35+
}
1336

1437
public static function text(): TextGenerator
1538
{

Diff for: src/Requests/TextRequest.php

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class TextRequest
1818
public function __construct(
1919
public readonly string $model,
2020
public readonly ?string $systemPrompt,
21+
public readonly ?string $prompt,
2122
public readonly array $messages,
2223
public readonly ?int $maxTokens,
2324
public readonly int|float|null $temperature,

Diff for: src/Testing/PrismFake.php

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace EchoLabs\Prism\Testing;
6+
7+
use Closure;
8+
use EchoLabs\Prism\Contracts\Provider;
9+
use EchoLabs\Prism\Enums\FinishReason;
10+
use EchoLabs\Prism\Providers\ProviderResponse;
11+
use EchoLabs\Prism\Requests\TextRequest;
12+
use EchoLabs\Prism\ValueObjects\Usage;
13+
use Exception;
14+
use PHPUnit\Framework\Assert as PHPUnit;
15+
16+
class PrismFake implements Provider
17+
{
18+
protected int $responseSequence = 0;
19+
20+
/** @var array<int, TextRequest> */
21+
protected array $recorded = [];
22+
23+
/**
24+
* @param array<int, ProviderResponse> $responses
25+
*/
26+
public function __construct(protected array $responses = []) {}
27+
28+
#[\Override]
29+
public function text(TextRequest $request): ProviderResponse
30+
{
31+
$this->recorded[] = $request;
32+
33+
return $this->nextResponse() ?? new ProviderResponse(
34+
text: '',
35+
toolCalls: [],
36+
usage: new Usage(0, 0),
37+
finishReason: FinishReason::Stop,
38+
response: ['id' => 'fake', 'model' => 'fake']
39+
);
40+
}
41+
42+
/**
43+
* @param Closure(array<int, TextRequest>):void $fn
44+
*/
45+
public function assertRequest(Closure $fn): void
46+
{
47+
$fn($this->recorded);
48+
}
49+
50+
public function assertPrompt(string $prompt): void
51+
{
52+
$prompts = collect($this->recorded)
53+
->flatten()
54+
->map
55+
->prompt;
56+
57+
PHPUnit::assertTrue(
58+
$prompts->contains($prompt),
59+
"Could not find the prompt {$prompt} in the recorded requests"
60+
);
61+
}
62+
63+
/**
64+
* Assert number of calls made
65+
*/
66+
public function assertCallCount(int $expectedCount): void
67+
{
68+
$actualCount = count($this->recorded ?? []);
69+
70+
PHPUnit::assertEquals($expectedCount, $actualCount, "Expected {$expectedCount} calls, got {$actualCount}");
71+
}
72+
73+
protected function nextResponse(): ?ProviderResponse
74+
{
75+
if (! isset($this->responses)) {
76+
return null;
77+
}
78+
79+
$responses = $this->responses;
80+
$sequence = $this->responseSequence;
81+
82+
if (! isset($responses[$sequence])) {
83+
throw new Exception('Could not find a response for the request');
84+
}
85+
86+
$this->responseSequence++;
87+
88+
return $responses[$sequence];
89+
}
90+
}

Diff for: tests/Testing/PrismFakeTest.php

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Testing;
6+
7+
use EchoLabs\Prism\Enums\FinishReason;
8+
use EchoLabs\Prism\Prism;
9+
use EchoLabs\Prism\Providers\ProviderResponse;
10+
use EchoLabs\Prism\Requests\TextRequest;
11+
use EchoLabs\Prism\ValueObjects\Usage;
12+
use Exception;
13+
14+
it('fake responses using the prism fake', function (): void {
15+
$fake = Prism::fake([
16+
new ProviderResponse(
17+
text: 'The meaning of life is 42',
18+
toolCalls: [],
19+
usage: new Usage(42, 42),
20+
finishReason: FinishReason::Stop,
21+
response: ['id' => 'cpl_1234', 'model' => 'claude-3-sonnet'],
22+
),
23+
]);
24+
25+
Prism::text()
26+
->using('anthropic', 'claude-3-sonnet')
27+
->withPrompt('What is the meaning of life?')
28+
->generate();
29+
30+
$fake->assertCallCount(1);
31+
$fake->assertPrompt('What is the meaning of life?');
32+
$fake->assertRequest(function (array $requests): void {
33+
expect($requests)->toHaveCount(1);
34+
expect($requests[0])->toBeInstanceOf(TextRequest::class);
35+
});
36+
});
37+
38+
it("throws an exception when it can't runs out of responses", function (): void {
39+
$this->expectException(Exception::class);
40+
$this->expectExceptionMessage('Could not find a response for the request');
41+
42+
Prism::fake([
43+
new ProviderResponse(
44+
text: 'The meaning of life is 42',
45+
toolCalls: [],
46+
usage: new Usage(42, 42),
47+
finishReason: FinishReason::Stop,
48+
response: ['id' => 'cpl_1234', 'model' => 'claude-3-sonnet'],
49+
),
50+
]);
51+
52+
Prism::text()
53+
->using('anthropic', 'claude-3-sonnet')
54+
->withPrompt('What is the meaning of life?')
55+
->generate();
56+
57+
Prism::text()
58+
->using('anthropic', 'claude-3-sonnet')
59+
->withPrompt('What is the meaning of life?')
60+
->generate();
61+
});

0 commit comments

Comments
 (0)