Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 165 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ Then, add the extension to your PHPUnit configuration file.
## Usage

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

When running the tests, the library will automatically turn the recorder on and off, and insert the cassettes when
needed.
Expand All @@ -54,7 +55,7 @@ responses in the given cassette.
{
#[Test]
public function example(): void { ... }

#[Test]
public function another(): void { ... }
}
Expand Down Expand Up @@ -102,4 +103,165 @@ used for that method. In this example, the responses from the requests made in t
#[UseCassette("example_2.yml")]
public function recorded(): void { ... }
}
```
```

## DataProvider Support

The library supports PHPUnit's `DataProvider` functionality with additional options for managing cassettes when using data providers.

### Basic DataProvider Usage

When using a data provider with the basic `UseCassette` attribute, all test cases from the data provider will share the same cassette file:

```php
use Angelov\PHPUnitPHPVcr\UseCassette;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

class ExampleTest extends TestCase
{
#[Test]
#[UseCassette("shared_cassette.yml")]
#[DataProvider("urls")]
public function testWithDataProvider(string $url): void
{
$content = file_get_contents($url);
// All test cases will use the same cassette file
}

public static function urls(): iterable
{
yield ["https://example.com"];
yield ["https://example.org"];
}
}
```

### Separate Cassettes Per DataProvider Case

For more granular control, you can create separate cassette files for each data provider case using the `separateCassettePerCase` parameter:

```php
use Angelov\PHPUnitPHPVcr\UseCassette;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

class ExampleTest extends TestCase
{
#[Test]
#[UseCassette(name: "separate_cassettes.yml", separateCassettePerCase: true)]
#[DataProvider("urls")]
public function testWithSeparateCassettes(string $url): void
{
$content = file_get_contents($url);
// Each test case will have its own cassette file:
// - separate_cassettes-0.yml
// - separate_cassettes-1.yml
}

public static function urls(): iterable
{
yield ["https://example.com"];
yield ["https://example.org"];
}
}
```

### Named DataProvider Cases

When using named data provider cases, the cassette files will use the case names:

```php
use Angelov\PHPUnitPHPVcr\UseCassette;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

class ExampleTest extends TestCase
{
#[Test]
#[UseCassette(name: "named_cassettes.yml", separateCassettePerCase: true)]
#[DataProvider("namedUrls")]
public function testWithNamedCassettes(string $url): void
{
$content = file_get_contents($url);
// Each test case will have its own cassette file:
// - named_cassettes-example-com.yml
// - named_cassettes-example-org.yml
}

public static function namedUrls(): iterable
{
yield 'example.com' => ["https://example.com"];
yield 'example.org' => ["https://example.org"];
}
}
```

### Grouping Cassettes in Directories

To organize separate cassette files in directories, use the `groupCaseFilesInDirectory` parameter:

```php
use Angelov\PHPUnitPHPVcr\UseCassette;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

class ExampleTest extends TestCase
{
#[Test]
#[UseCassette(
name: "organized_cassettes.yml",
separateCassettePerCase: true,
groupCaseFilesInDirectory: true
)]
#[DataProvider("urls")]
public function testWithOrganizedCassettes(string $url): void
{
$content = file_get_contents($url);
// Cassette files will be organized in a directory:
// - organized_cassettes/0.yml
// - organized_cassettes/1.yml
}

public static function urls(): iterable
{
yield ["https://example.com"];
yield ["https://example.org"];
}
}
```

### Class-Level DataProvider Support

The dataProvider functionality also works when the `UseCassette` attribute is declared at the class level:

```php
use Angelov\PHPUnitPHPVcr\UseCassette;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

#[UseCassette(name: "class_level.yml", separateCassettePerCase: true)]
class ExampleTest extends TestCase
{
#[Test]
#[DataProvider("urls")]
public function testMethod(string $url): void
{
$content = file_get_contents($url);
// Each test case will have separate cassettes:
// - class_level-0.yml
// - class_level-1.yml
}

public static function urls(): iterable
{
yield ["https://example.com"];
yield ["https://example.org"];
}
}
```
46 changes: 26 additions & 20 deletions src/Subscribers/AttributeResolverTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,59 +5,65 @@
namespace Angelov\PHPUnitPHPVcr\Subscribers;

use Angelov\PHPUnitPHPVcr\UseCassette;
use Angelov\PHPUnitPHPVcr\Values\TestCaseParameters;
use Angelov\PHPUnitPHPVcr\Values\TestMethodInfo;
use Exception;
use ReflectionMethod;

trait AttributeResolverTrait
{
private function needsRecording(string $test): bool
{
return $this->getAttribute($test) !== null;
return $this->getTestCaseCassetteParameters($test) !== null;
}

private function getCassetteName(string $test): ?string
private function getTestCaseCassetteParameters(string $test): ?TestCaseParameters
{
return $this->getAttribute($test)?->name;
}

private function getAttribute(string $test): ?UseCassette
{
$test = $this->parseMethod($test);
$testMethodDetails = $this->parseMethod($test);

try {
if (PHP_VERSION_ID < 80300) {
$method = new ReflectionMethod($test);
$method = new ReflectionMethod($testMethodDetails->method);
} else {
// @phpstan-ignore-next-line
$method = ReflectionMethod::createFromMethodName($test);
$method = ReflectionMethod::createFromMethodName($testMethodDetails->method);
}
} catch (Exception) {
return null;
}

$attributes = $method->getAttributes(UseCassette::class);
$cassetteAttribute = $method->getAttributes(UseCassette::class);

if ($attributes) {
return $attributes[0]->newInstance();
$cassetteAttributeInstance = $cassetteAttribute
? $cassetteAttribute[0]->newInstance() : $this->getAttributeFromClass($testMethodDetails);

if ($cassetteAttributeInstance === null) {
return null;
}

return $this->getAttributeFromClass($test);
return new TestCaseParameters(
cassetteInfo: $cassetteAttributeInstance,
case: $testMethodDetails->dataProvider,
);
}

private function parseMethod(string $test): string
private function parseMethod(string $test): TestMethodInfo
{
$test = explode(" ", $test)[0];
$methodDetails = explode("#", $test);

return explode("#", $test)[0];
return new TestMethodInfo(
method: $methodDetails[0],
dataProvider: $methodDetails[1] ?? null
);
}

private function getAttributeFromClass(string $test): ?UseCassette
private function getAttributeFromClass(TestMethodInfo $test): ?UseCassette
{
if (PHP_VERSION_ID < 80300) {
$method = new ReflectionMethod($test);
$method = new ReflectionMethod($test->method);
} else {
// @phpstan-ignore-next-line
$method = ReflectionMethod::createFromMethodName($test);
$method = ReflectionMethod::createFromMethodName($test->method);
}
$class = $method->getDeclaringClass();
$attributes = $class->getAttributes(UseCassette::class);
Expand Down
34 changes: 32 additions & 2 deletions src/Subscribers/StartRecording.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Angelov\PHPUnitPHPVcr\Subscribers;

use Angelov\PHPUnitPHPVcr\UseCassette;
use Angelov\PHPUnitPHPVcr\Values\TestCaseParameters;
use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber;
use VCR\VCR;
Expand All @@ -20,10 +22,38 @@ public function notify(Prepared $event): void
return;
}

$cassetteName = $this->getCassetteName($test);
assert($cassetteName !== null);
$testCaseCassetteParameters = $this->getTestCaseCassetteParameters($test);
assert($testCaseCassetteParameters instanceof TestCaseParameters);

if ($testCaseCassetteParameters->case !== null) {
$cassetteName = $this->makeCassetteNameForCase(
case: $testCaseCassetteParameters->case,
cassetteInfo: $testCaseCassetteParameters->cassetteInfo,
);
} else {
$cassetteName = $testCaseCassetteParameters->cassetteInfo->name;
}

VCR::turnOn();
VCR::insertCassette($cassetteName);
}

private function makeCassetteNameForCase(string $case, UseCassette $cassetteInfo): string
{
if (!$cassetteInfo->separateCassettePerCase) {
return $cassetteInfo->name;
}

$cassetteNameParts = explode('.', $cassetteInfo->name);
$cassetteSuffix = $cassetteInfo->groupCaseFilesInDirectory ? '/' . $case : '-' . $case;

if (count($cassetteNameParts) === 1) {
//the cassette name does not contain a dot, so we can use it as is
return $cassetteInfo->name . $cassetteSuffix;
}

$ext = array_pop($cassetteNameParts);

return implode('.', $cassetteNameParts) . $cassetteSuffix . '.' . $ext;
}
}
9 changes: 6 additions & 3 deletions src/UseCassette.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
use Attribute;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
class UseCassette
readonly class UseCassette
{
public function __construct(public readonly string $name)
{
public function __construct(
public string $name,
public bool $separateCassettePerCase = false,
public bool $groupCaseFilesInDirectory = false
) {
}
}
16 changes: 16 additions & 0 deletions src/Values/TestCaseParameters.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Angelov\PHPUnitPHPVcr\Values;

use Angelov\PHPUnitPHPVcr\UseCassette;

readonly class TestCaseParameters
{
public function __construct(
public UseCassette $cassetteInfo,
public ?string $case = null,
) {
}
}
34 changes: 34 additions & 0 deletions src/Values/TestMethodInfo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Angelov\PHPUnitPHPVcr\Values;

use InvalidArgumentException;

readonly class TestMethodInfo
{
public ?string $dataProvider;

public function __construct(
public string $method,
?string $dataProvider = null
) {
$this->dataProvider = $this->normaliseDataProvider($dataProvider);
}

private function normaliseDataProvider(?string $dataProvider): ?string
{
if ($dataProvider === null) {
return null;
}

$replaced = (string)preg_replace('/-+/', '-', (string)preg_replace('/\W+/', '-', $dataProvider));

if ($replaced === '') {
throw new InvalidArgumentException('Invalid data provider name: ' . $dataProvider);
}

return trim(strtolower($replaced));
}
}
Loading