Skip to content

Commit c6c9fd7

Browse files
committed
feat: separate cassettes per dataProvider cases
1 parent a1c7bca commit c6c9fd7

29 files changed

+922
-28
lines changed

README.md

Lines changed: 167 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# PHP-VCR integration for PHPUnit
22

3+
![Coverage](https://raw.githubusercontent.com/angelov/phpunit-php-vcr/image-data/coverage.svg)
4+
35
A library that allows you to easily use the PHP-VCR library in your PHPUnit tests.
46

57
## Requirements
@@ -34,7 +36,8 @@ Then, add the extension to your PHPUnit configuration file.
3436
## Usage
3537

3638
The library provides an `UseCassette` attribute that can be declared on test classes or specific test methods. The
37-
attribute expects one string argument - the name of the cassette.
39+
attribute accepts a cassette name and optional parameters for advanced functionality like separate cassettes per
40+
data provider case.
3841

3942
When running the tests, the library will automatically turn the recorder on and off, and insert the cassettes when
4043
needed.
@@ -54,7 +57,7 @@ responses in the given cassette.
5457
{
5558
#[Test]
5659
public function example(): void { ... }
57-
60+
5861
#[Test]
5962
public function another(): void { ... }
6063
}
@@ -102,4 +105,165 @@ used for that method. In this example, the responses from the requests made in t
102105
#[UseCassette("example_2.yml")]
103106
public function recorded(): void { ... }
104107
}
105-
```
108+
```
109+
110+
## DataProvider Support
111+
112+
The library supports PHPUnit's `DataProvider` functionality with additional options for managing cassettes when using data providers.
113+
114+
### Basic DataProvider Usage
115+
116+
When using a data provider with the basic `UseCassette` attribute, all test cases from the data provider will share the same cassette file:
117+
118+
```php
119+
use Angelov\PHPUnitPHPVcr\UseCassette;
120+
use PHPUnit\Framework\Attributes\DataProvider;
121+
use PHPUnit\Framework\Attributes\Test;
122+
use PHPUnit\Framework\TestCase;
123+
124+
class ExampleTest extends TestCase
125+
{
126+
#[Test]
127+
#[UseCassette("shared_cassette.yml")]
128+
#[DataProvider("urls")]
129+
public function testWithDataProvider(string $url): void
130+
{
131+
$content = file_get_contents($url);
132+
// All test cases will use the same cassette file
133+
}
134+
135+
public static function urls(): iterable
136+
{
137+
yield ["https://example.com"];
138+
yield ["https://example.org"];
139+
}
140+
}
141+
```
142+
143+
### Separate Cassettes Per DataProvider Case
144+
145+
For more granular control, you can create separate cassette files for each data provider case using the `separateCassettePerCase` parameter:
146+
147+
```php
148+
use Angelov\PHPUnitPHPVcr\UseCassette;
149+
use PHPUnit\Framework\Attributes\DataProvider;
150+
use PHPUnit\Framework\Attributes\Test;
151+
use PHPUnit\Framework\TestCase;
152+
153+
class ExampleTest extends TestCase
154+
{
155+
#[Test]
156+
#[UseCassette(name: "separate_cassettes.yml", separateCassettePerCase: true)]
157+
#[DataProvider("urls")]
158+
public function testWithSeparateCassettes(string $url): void
159+
{
160+
$content = file_get_contents($url);
161+
// Each test case will have its own cassette file:
162+
// - separate_cassettes-0.yml
163+
// - separate_cassettes-1.yml
164+
}
165+
166+
public static function urls(): iterable
167+
{
168+
yield ["https://example.com"];
169+
yield ["https://example.org"];
170+
}
171+
}
172+
```
173+
174+
### Named DataProvider Cases
175+
176+
When using named data provider cases, the cassette files will use the case names:
177+
178+
```php
179+
use Angelov\PHPUnitPHPVcr\UseCassette;
180+
use PHPUnit\Framework\Attributes\DataProvider;
181+
use PHPUnit\Framework\Attributes\Test;
182+
use PHPUnit\Framework\TestCase;
183+
184+
class ExampleTest extends TestCase
185+
{
186+
#[Test]
187+
#[UseCassette(name: "named_cassettes.yml", separateCassettePerCase: true)]
188+
#[DataProvider("namedUrls")]
189+
public function testWithNamedCassettes(string $url): void
190+
{
191+
$content = file_get_contents($url);
192+
// Each test case will have its own cassette file:
193+
// - named_cassettes-example-com.yml
194+
// - named_cassettes-example-org.yml
195+
}
196+
197+
public static function namedUrls(): iterable
198+
{
199+
yield 'example.com' => ["https://example.com"];
200+
yield 'example.org' => ["https://example.org"];
201+
}
202+
}
203+
```
204+
205+
### Grouping Cassettes in Directories
206+
207+
To organize separate cassette files in directories, use the `groupCaseFilesInDirectory` parameter:
208+
209+
```php
210+
use Angelov\PHPUnitPHPVcr\UseCassette;
211+
use PHPUnit\Framework\Attributes\DataProvider;
212+
use PHPUnit\Framework\Attributes\Test;
213+
use PHPUnit\Framework\TestCase;
214+
215+
class ExampleTest extends TestCase
216+
{
217+
#[Test]
218+
#[UseCassette(
219+
name: "organized_cassettes.yml",
220+
separateCassettePerCase: true,
221+
groupCaseFilesInDirectory: true
222+
)]
223+
#[DataProvider("urls")]
224+
public function testWithOrganizedCassettes(string $url): void
225+
{
226+
$content = file_get_contents($url);
227+
// Cassette files will be organized in a directory:
228+
// - organized_cassettes/0.yml
229+
// - organized_cassettes/1.yml
230+
}
231+
232+
public static function urls(): iterable
233+
{
234+
yield ["https://example.com"];
235+
yield ["https://example.org"];
236+
}
237+
}
238+
```
239+
240+
### Class-Level DataProvider Support
241+
242+
The dataProvider functionality also works when the `UseCassette` attribute is declared at the class level:
243+
244+
```php
245+
use Angelov\PHPUnitPHPVcr\UseCassette;
246+
use PHPUnit\Framework\Attributes\DataProvider;
247+
use PHPUnit\Framework\Attributes\Test;
248+
use PHPUnit\Framework\TestCase;
249+
250+
#[UseCassette(name: "class_level.yml", separateCassettePerCase: true)]
251+
class ExampleTest extends TestCase
252+
{
253+
#[Test]
254+
#[DataProvider("urls")]
255+
public function testMethod(string $url): void
256+
{
257+
$content = file_get_contents($url);
258+
// Each test case will have separate cassettes:
259+
// - class_level-0.yml
260+
// - class_level-1.yml
261+
}
262+
263+
public static function urls(): iterable
264+
{
265+
yield ["https://example.com"];
266+
yield ["https://example.org"];
267+
}
268+
}
269+
```

src/Subscribers/AttributeResolverTrait.php

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,59 +5,65 @@
55
namespace Angelov\PHPUnitPHPVcr\Subscribers;
66

77
use Angelov\PHPUnitPHPVcr\UseCassette;
8+
use Angelov\PHPUnitPHPVcr\Values\TestCaseParameters;
9+
use Angelov\PHPUnitPHPVcr\Values\TestMethodInfo;
810
use Exception;
911
use ReflectionMethod;
1012

1113
trait AttributeResolverTrait
1214
{
1315
private function needsRecording(string $test): bool
1416
{
15-
return $this->getAttribute($test) !== null;
17+
return $this->getTestCaseCassetteParameters($test) !== null;
1618
}
1719

18-
private function getCassetteName(string $test): ?string
20+
private function getTestCaseCassetteParameters(string $test): ?TestCaseParameters
1921
{
20-
return $this->getAttribute($test)?->name;
21-
}
22-
23-
private function getAttribute(string $test): ?UseCassette
24-
{
25-
$test = $this->parseMethod($test);
22+
$testMethodDetails = $this->parseMethod($test);
2623

2724
try {
2825
if (PHP_VERSION_ID < 80300) {
29-
$method = new ReflectionMethod($test);
26+
$method = new ReflectionMethod($testMethodDetails->method);
3027
} else {
3128
// @phpstan-ignore-next-line
32-
$method = ReflectionMethod::createFromMethodName($test);
29+
$method = ReflectionMethod::createFromMethodName($testMethodDetails->method);
3330
}
3431
} catch (Exception) {
3532
return null;
3633
}
3734

38-
$attributes = $method->getAttributes(UseCassette::class);
35+
$cassetteAttribute = $method->getAttributes(UseCassette::class);
3936

40-
if ($attributes) {
41-
return $attributes[0]->newInstance();
37+
$cassetteAttributeInstance = $cassetteAttribute
38+
? $cassetteAttribute[0]->newInstance() : $this->getAttributeFromClass($testMethodDetails);
39+
40+
if ($cassetteAttributeInstance === null) {
41+
return null;
4242
}
4343

44-
return $this->getAttributeFromClass($test);
44+
return new TestCaseParameters(
45+
cassetteAttribute: $cassetteAttributeInstance,
46+
case: $testMethodDetails->dataProvider,
47+
);
4548
}
4649

47-
private function parseMethod(string $test): string
50+
private function parseMethod(string $test): TestMethodInfo
4851
{
49-
$test = explode(" ", $test)[0];
52+
$methodDetails = explode("#", $test);
5053

51-
return explode("#", $test)[0];
54+
return new TestMethodInfo(
55+
method: $methodDetails[0],
56+
dataProvider: $methodDetails[1] ?? null
57+
);
5258
}
5359

54-
private function getAttributeFromClass(string $test): ?UseCassette
60+
private function getAttributeFromClass(TestMethodInfo $test): ?UseCassette
5561
{
5662
if (PHP_VERSION_ID < 80300) {
57-
$method = new ReflectionMethod($test);
63+
$method = new ReflectionMethod($test->method);
5864
} else {
5965
// @phpstan-ignore-next-line
60-
$method = ReflectionMethod::createFromMethodName($test);
66+
$method = ReflectionMethod::createFromMethodName($test->method);
6167
}
6268
$class = $method->getDeclaringClass();
6369
$attributes = $class->getAttributes(UseCassette::class);

src/Subscribers/StartRecording.php

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
namespace Angelov\PHPUnitPHPVcr\Subscribers;
66

7+
use Angelov\PHPUnitPHPVcr\UseCassette;
8+
use Angelov\PHPUnitPHPVcr\Values\TestCaseParameters;
79
use PHPUnit\Event\Test\Prepared;
810
use PHPUnit\Event\Test\PreparedSubscriber;
911
use VCR\VCR;
@@ -20,10 +22,38 @@ public function notify(Prepared $event): void
2022
return;
2123
}
2224

23-
$cassetteName = $this->getCassetteName($test);
24-
assert($cassetteName !== null);
25+
$testCaseCassetteParameters = $this->getTestCaseCassetteParameters($test);
26+
assert($testCaseCassetteParameters instanceof TestCaseParameters);
27+
28+
if ($testCaseCassetteParameters->case !== null) {
29+
$cassetteName = $this->makeCassetteNameForCase(
30+
case: $testCaseCassetteParameters->case,
31+
cassette: $testCaseCassetteParameters->cassetteAttribute,
32+
);
33+
} else {
34+
$cassetteName = $testCaseCassetteParameters->cassetteAttribute->name;
35+
}
2536

2637
VCR::turnOn();
2738
VCR::insertCassette($cassetteName);
2839
}
40+
41+
private function makeCassetteNameForCase(string $case, UseCassette $cassette): string
42+
{
43+
if (!$cassette->separateCassettePerCase) {
44+
return $cassette->name;
45+
}
46+
47+
$cassetteNameParts = explode('.', $cassette->name);
48+
$cassetteSuffix = $cassette->groupCaseFilesInDirectory ? '/' . $case : '-' . $case;
49+
50+
if (count($cassetteNameParts) === 1) {
51+
//the cassette name does not contain a dot, so we can use it as is
52+
return $cassette->name . $cassetteSuffix;
53+
}
54+
55+
$ext = array_pop($cassetteNameParts);
56+
57+
return implode('.', $cassetteNameParts) . $cassetteSuffix . '.' . $ext;
58+
}
2959
}

src/UseCassette.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
use Attribute;
88

99
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
10-
class UseCassette
10+
readonly class UseCassette
1111
{
12-
public function __construct(public readonly string $name)
13-
{
12+
public function __construct(
13+
public string $name,
14+
public bool $separateCassettePerCase = false,
15+
public bool $groupCaseFilesInDirectory = false
16+
) {
1417
}
1518
}

src/Values/TestCaseParameters.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Angelov\PHPUnitPHPVcr\Values;
6+
7+
use Angelov\PHPUnitPHPVcr\UseCassette;
8+
9+
readonly class TestCaseParameters
10+
{
11+
public function __construct(
12+
public UseCassette $cassetteAttribute,
13+
public ?string $case = null,
14+
) {
15+
}
16+
}

src/Values/TestMethodInfo.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Angelov\PHPUnitPHPVcr\Values;
6+
7+
use InvalidArgumentException;
8+
9+
readonly class TestMethodInfo
10+
{
11+
public ?string $dataProvider;
12+
13+
public function __construct(
14+
public string $method,
15+
?string $dataProvider = null
16+
) {
17+
$this->dataProvider = $this->normaliseDataProvider($dataProvider);
18+
}
19+
20+
private function normaliseDataProvider(?string $dataProvider): ?string
21+
{
22+
if ($dataProvider === null) {
23+
return null;
24+
}
25+
26+
$replaced = (string)preg_replace('/-+/', '-', (string)preg_replace('/\W+/', '-', $dataProvider));
27+
28+
if ($replaced === '') {
29+
throw new InvalidArgumentException('Invalid data provider name: ' . $dataProvider);
30+
}
31+
32+
return trim(strtolower($replaced));
33+
}
34+
}

0 commit comments

Comments
 (0)