diff --git a/bridge/vcr/VCR.php b/bridge/vcr/VCR.php index 54b652c3..a08c461e 100644 --- a/bridge/vcr/VCR.php +++ b/bridge/vcr/VCR.php @@ -60,7 +60,7 @@ * @param RecordMode|null $mode Record mode for this test; `null` inherits php-vcr's global default * ({@see RecordMode::NewEpisodes}). * @param list $match Request matchers for this test; an empty list inherits php-vcr's - * default (method + URL). + * default (every available matcher). */ public function __construct( public string $name, diff --git a/testo.php b/testo.php index 69b9941c..fc991912 100644 --- a/testo.php +++ b/testo.php @@ -50,6 +50,7 @@ name: 'sandbox', location: new FinderConfig( include: ['tests/Sandbox'], + exclude: ['tests/Sandbox/Sample'], ), ), ], diff --git a/tests/Sandbox/Sample/01_OrderAssertions.php b/tests/Sandbox/Sample/01_OrderAssertions.php new file mode 100644 index 00000000..901227c4 --- /dev/null +++ b/tests/Sandbox/Sample/01_OrderAssertions.php @@ -0,0 +1,72 @@ +assert*`. + */ + +namespace Sample\PhpUnit { + + use App\Checkout\Order; + use PHPUnit\Framework\TestCase; + + final class OrderTest extends TestCase + { + public function testPlacesOrder(): void + { + $order = Order::place(customerId: 42, items: ['sku-1' => 2, 'sku-2' => 1]); + + $this->assertIsString($order->number); + $this->assertStringStartsWith('ORD-', $order->number); + $this->assertStringNotContainsString(' ', $order->number); + + $this->assertIsInt($order->total); + $this->assertGreaterThan(0, $order->total); + $this->assertLessThanOrEqual(1_000_000, $order->total); + + $this->assertIsArray($order->lines); + $this->assertCount(2, $order->lines); + $this->assertArrayHasKey('sku-1', $order->lines); + $this->assertArrayHasKey('sku-2', $order->lines); + $this->assertArrayNotHasKey('sku-3', $order->lines); + $this->assertContainsOnlyInstancesOf(Order\Line::class, $order->lines); + + $this->assertSame(42, $order->customerId); + $this->assertNull($order->paidAt); + } + } +} + +namespace Sample\Testo { + + use App\Checkout\Order; + use Testo\Assert; + use Testo\Test; + + #[Test] + final class OrderTest + { + public function placesOrder(): void + { + $order = Order::place(customerId: 42, items: ['sku-1' => 2, 'sku-2' => 1]); + + Assert::string($order->number) + ->contains('ORD-') + ->notContains(' '); + + Assert::int($order->total) + ->greaterThan(0) + ->lessThanOrEqual(1_000_000); + + Assert::array($order->lines) + ->hasCount(2) + ->hasKeys('sku-1', 'sku-2') + ->doesNotHaveKeys('sku-3') + ->allOf(Order\Line::class); + + Assert::same($order->customerId, 42); + Assert::null($order->paidAt); + } + } +} diff --git a/tests/Sandbox/Sample/02_JsonResponse.php b/tests/Sandbox/Sample/02_JsonResponse.php new file mode 100644 index 00000000..60b9f7f5 --- /dev/null +++ b/tests/Sandbox/Sample/02_JsonResponse.php @@ -0,0 +1,65 @@ +get('/api/users/42')->getBody()->getContents(); + + $this->assertJson($body); + $json = \json_decode($body, true, flags: \JSON_THROW_ON_ERROR); + + $this->assertIsArray($json); + $this->assertArrayHasKey('data', $json); + $this->assertArrayHasKey('meta', $json); + + $this->assertIsArray($json['data']); + $this->assertArrayHasKey('id', $json['data']); + $this->assertSame(42, $json['data']['id']); + $this->assertArrayHasKey('email', $json['data']); + $this->assertStringContainsString('@', $json['data']['email']); + + $this->assertArrayHasKey('roles', $json['data']); + $this->assertIsArray($json['data']['roles']); + $this->assertCount(2, $json['data']['roles']); + $this->assertContains('admin', $json['data']['roles']); + } + } +} + +namespace Sample\Testo { + + use App\Api\Client; + use Testo\Assert; + use Testo\Test; + + #[Test] + final class UserEndpointTest + { + public function returnsUserProfile(): void + { + $body = (new Client())->get('/api/users/42')->getBody()->getContents(); + + Assert::json($body) + ->isObject() + ->hasKeys(['data', 'meta']) + ->assertPath('$.data.id', fn($id) => Assert::same($id->decode(), 42)) + ->assertPath('$.data.email', fn($email) => Assert::string($email->decode()) + ->contains('@')) + ->assertPath('$.data.roles', fn($roles) => Assert::array($roles->decode()) + ->hasCount(2) + ->contains('admin')); + } + } +} diff --git a/tests/Sandbox/Sample/03_ExpectException.php b/tests/Sandbox/Sample/03_ExpectException.php new file mode 100644 index 00000000..46c7c935 --- /dev/null +++ b/tests/Sandbox/Sample/03_ExpectException.php @@ -0,0 +1,76 @@ +expectException(PaymentFailed::class); + $this->expectExceptionMessage('Charge of 1500 declined'); + + $wallet->charge(1500); + } + + public function testKeepsGatewayErrorAsCause(): void + { + $wallet = new Wallet(new PaymentGateway(declineAll: true)); + + try { + $wallet->charge(1500); + $this->fail('PaymentFailed was not thrown'); + } catch (PaymentFailed $e) { + $this->assertSame(PaymentFailed::DECLINED, $e->getCode()); + + $previous = $e->getPrevious(); + $this->assertInstanceOf(PaymentGateway\Declined::class, $previous); + $this->assertSame('insufficient_funds', $previous->getMessage()); + } + } + } +} + +namespace Sample\Testo { + + use App\Billing\PaymentFailed; + use App\Billing\PaymentGateway; + use App\Billing\Wallet; + use Testo\Expect; + use Testo\Test; + + /** + * Testo's `Expect` allows a single chain to assert the exception type, message, code, and cause, without try/catch. + */ + #[Test] + final class WalletTest + { + public function rejectsChargeWhenGatewayDeclines(): never + { + $wallet = new Wallet(new PaymentGateway(declineAll: true)); + + Expect::exception(PaymentFailed::class) + ->withMessage('Charge of 1500 declined') + ->withCode(PaymentFailed::DECLINED) + ->withPrevious( + PaymentGateway\Declined::class, + static fn($cause) => $cause->withMessage('insufficient_funds'), + ); + + $wallet->charge(1500); + } + } +} diff --git a/tests/Sandbox/Sample/04_Collection.php b/tests/Sandbox/Sample/04_Collection.php new file mode 100644 index 00000000..95c39c0c --- /dev/null +++ b/tests/Sandbox/Sample/04_Collection.php @@ -0,0 +1,62 @@ +findPublished(category: 'books'); + + $this->assertNotEmpty($products); + $this->assertCount(3, $products); + $this->assertContainsOnlyInstancesOf(Product::class, $products); + + foreach ($products as $product) { + $this->assertTrue($product->isPublished()); + $this->assertSame('books', $product->category); + $this->assertGreaterThan(0, $product->price); + } + + $ids = \array_map(static fn(Product $p) => $p->id, $products); + \sort($ids); + $this->assertSame([7, 12, 31], $ids); + } + } +} + +namespace Sample\Testo { + + use App\Catalog\Product; + use App\Catalog\ProductRepository; + use Testo\Assert; + use Testo\Test; + + #[Test] + final class ProductRepositoryTest + { + public function findsPublishedProductsInCategory(): void + { + $products = (new ProductRepository())->findPublished(category: 'books'); + + Assert::iterable($products) + ->notEmpty() + ->hasCount(3) + ->allOf(Product::class) + ->every(static fn(Product $p) => $p->isPublished() && $p->category === 'books' && $p->price > 0); + + Assert::array(\array_map(static fn(Product $p) => $p->id, $products)) + ->sameElementsAs([7, 12, 31]); + } + } +} diff --git a/tests/Sandbox/Sample/05_MemoryLeak.php b/tests/Sandbox/Sample/05_MemoryLeak.php new file mode 100644 index 00000000..01c8a72b --- /dev/null +++ b/tests/Sandbox/Sample/05_MemoryLeak.php @@ -0,0 +1,53 @@ +listen('order.placed', $listener); + + $dispatcher->forget('order.placed', $listener); + + $ref = \WeakReference::create($listener); + unset($listener); + \gc_collect_cycles(); + $this->assertNull($ref->get(), 'Listener is still referenced by the dispatcher'); + } + } +} + +namespace Sample\Testo { + + use App\Events\EventDispatcher; + use App\Events\OrderPlacedListener; + use Testo\Expect; + use Testo\Test; + + #[Test] + final class EventDispatcherTest + { + public function releasesListenerAfterRemoval(): void + { + $dispatcher = new EventDispatcher(); + $listener = new OrderPlacedListener(); + $dispatcher->listen('order.placed', $listener); + Expect::notLeaks($listener); + + $dispatcher->forget('order.placed', $listener); + } + } +} diff --git a/tests/Sandbox/Sample/06_DataSetInline.php b/tests/Sandbox/Sample/06_DataSetInline.php new file mode 100644 index 00000000..5e859bf2 --- /dev/null +++ b/tests/Sandbox/Sample/06_DataSetInline.php @@ -0,0 +1,48 @@ +assertSame($expected, Discount::apply($subtotal)); + } + } +} + +namespace Sample\Testo { + + use App\Pricing\Discount; + use Testo\Assert; + use Testo\Data\DataSet; + use Testo\Test; + + #[Test] + final class DiscountTest + { + #[DataSet([4_999, 4_999], 'below threshold')] + #[DataSet([5_000, 4_500], 'exactly threshold')] + #[DataSet([20_000, 17_000], 'silver tier')] + #[DataSet([100_000, 80_000], 'gold tier')] + public function appliesTieredDiscount(int $subtotal, int $expected): void + { + Assert::same(Discount::apply($subtotal), $expected); + } + } +} diff --git a/tests/Sandbox/Sample/07_DataProvider.php b/tests/Sandbox/Sample/07_DataProvider.php new file mode 100644 index 00000000..3d62dbe8 --- /dev/null +++ b/tests/Sandbox/Sample/07_DataProvider.php @@ -0,0 +1,57 @@ +assertFalse((new EmailValidator())->isValid($email)); + } + + public static function invalidEmails(): iterable + { + yield 'no at sign' => ['john.example.com']; + yield 'no domain' => ['john@']; + yield 'spaces' => ['john doe@example.com']; + yield 'empty' => ['']; + } + } +} + +namespace Sample\Testo { + + use App\Identity\EmailValidator; + use Testo\Assert; + use Testo\Data\DataProvider; + use Testo\Test; + + #[Test] + final class EmailValidatorTest + { + #[DataProvider('invalidEmails')] + public function rejectsInvalidEmail(string $email): void + { + Assert::false((new EmailValidator())->isValid($email)); + } + + public static function invalidEmails(): iterable + { + yield 'no at sign' => ['john.example.com']; + yield 'no domain' => ['john@']; + yield 'spaces' => ['john doe@example.com']; + yield 'empty' => ['']; + } + } +} diff --git a/tests/Sandbox/Sample/08_DataCross.php b/tests/Sandbox/Sample/08_DataCross.php new file mode 100644 index 00000000..8317bafb --- /dev/null +++ b/tests/Sandbox/Sample/08_DataCross.php @@ -0,0 +1,103 @@ +format(123_456, $currency); + + $this->assertStringContainsString('1', $formatted); + } + + #[DataProvider('currencyLocaleMatrix')] + public function testParsesBackWhatItFormatted(string $currency, string $locale): void + { + $formatter = new MoneyFormatter($locale); + + $this->assertSame(123_456, $formatter->parse($formatter->format(123_456, $currency), $currency)); + } + + #[DataProvider('currencies')] + public function testRoundsToMinorUnits(string $currency): void + { + $this->assertSame(100, (new MoneyFormatter('en_US'))->round(100.004, $currency)); + } + + public static function currencies(): Providers + { + return Providers::list('USD', 'EUR', 'JPY'); + } + + public static function locales(): Providers + { + return Providers::list('en_US', 'de_DE', 'ru_RU'); + } + + public static function currencyLocaleMatrix(): Providers + { + return Providers::cross(self::currencies(), self::locales()); + } + } +} + +namespace Sample\Testo { + + use App\Money\MoneyFormatter; + use Testo\Assert; + use Testo\Data\DataCross; + use Testo\Data\DataProvider; + use Testo\Test; + + #[Test] + final class MoneyFormatterTest + { + #[DataCross(new DataProvider('currencies'), new DataProvider('locales'))] + public function formatsEveryCurrencyInEveryLocale(string $currency, string $locale): void + { + $formatted = (new MoneyFormatter($locale))->format(123_456, $currency); + + Assert::string($formatted)->contains('1'); + } + + #[DataCross(new DataProvider('currencies'), new DataProvider('locales'))] + public function parsesBackWhatItFormatted(string $currency, string $locale): void + { + $formatter = new MoneyFormatter($locale); + + Assert::same($formatter->parse($formatter->format(123_456, $currency), $currency), 123_456); + } + + #[DataProvider('currencies')] + public function roundsToMinorUnits(string $currency): void + { + Assert::same((new MoneyFormatter('en_US'))->round(100.004, $currency), 100); + } + + public static function currencies(): iterable + { + return [['USD'], ['EUR'], ['JPY']]; + } + + public static function locales(): iterable + { + return [['en_US'], ['de_DE'], ['ru_RU']]; + } + } +} diff --git a/tests/Sandbox/Sample/09_LifecycleHooks.php b/tests/Sandbox/Sample/09_LifecycleHooks.php new file mode 100644 index 00000000..d59bd9cf --- /dev/null +++ b/tests/Sandbox/Sample/09_LifecycleHooks.php @@ -0,0 +1,113 @@ +migrate(); + } + + public static function tearDownAfterClass(): void + { + self::$connection->dropSchema(); + parent::tearDownAfterClass(); + } + + protected function setUp(): void + { + parent::setUp(); + self::$connection->beginTransaction(); + $this->repository = new OrderRepository(self::$connection); + } + + protected function tearDown(): void + { + self::$connection->rollBack(); + parent::tearDown(); + } + + public function testStoresAndLoadsOrder(): void + { + $id = $this->repository->store(customerId: 42, total: 1500); + + $this->assertSame(1500, $this->repository->load($id)->total); + } + } +} + +namespace Sample\Testo { + + use App\Orders\OrderRepository; + use App\Storage\Connection; + use Testo\Assert; + use Testo\Lifecycle\AfterClass; + use Testo\Lifecycle\AfterTest; + use Testo\Lifecycle\BeforeClass; + use Testo\Lifecycle\BeforeTest; + use Testo\Test; + use Testo\Testing\Attribute\Inject; + + #[Test] + final class OrderRepositoryTest + { + #[Inject] + private Connection $connection; + + private OrderRepository $repository; + + #[BeforeClass] + public static function migrate(): void + { + Connection::fromEnv()->migrate(); + } + + #[AfterClass] + public static function dropSchema(): void + { + Connection::fromEnv()->dropSchema(); + } + + #[BeforeTest] + public function openTransaction(): void + { + $this->connection->beginTransaction(); + } + + #[BeforeTest] + public function createRepository(): void + { + $this->repository = new OrderRepository($this->connection); + } + + #[AfterTest] + public function rollBack(): void + { + $this->connection->rollBack(); + } + + public function storesAndLoadsOrder(): void + { + $id = $this->repository->store(customerId: 42, total: 1500); + + Assert::same($this->repository->load($id)->total, 1500); + } + } +} diff --git a/tests/Sandbox/Sample/10_FunctionTests.php b/tests/Sandbox/Sample/10_FunctionTests.php new file mode 100644 index 00000000..009072eb --- /dev/null +++ b/tests/Sandbox/Sample/10_FunctionTests.php @@ -0,0 +1,66 @@ +slugger = new Slugger(locale: 'ru'); + } + + public function testTransliteratesCyrillic(): void + { + $this->assertSame('privet-mir', $this->slugger->slug('Привет, мир!')); + } + + public function testCollapsesRepeatedSeparators(): void + { + $this->assertSame('a-b', $this->slugger->slug('a --- b')); + } + } +} + +namespace Sample\Testo { + + use App\Text\Slugger; + use Testo\Assert; + use Testo\Lifecycle\BeforeTest; + use Testo\Test; + + #[BeforeTest] + function createSlugger(): void + { + Fixture::$slugger = new Slugger(locale: 'ru'); + } + + #[Test] + function transliteratesCyrillic(): void + { + Assert::same(Fixture::$slugger->slug('Привет, мир!'), 'privet-mir'); + } + + #[Test] + function collapsesRepeatedSeparators(): void + { + Assert::same(Fixture::$slugger->slug('a --- b'), 'a-b'); + } + + final class Fixture + { + public static Slugger $slugger; + } +} diff --git a/tests/Sandbox/Sample/11_Vcr.php b/tests/Sandbox/Sample/11_Vcr.php new file mode 100644 index 00000000..c69c14f9 --- /dev/null +++ b/tests/Sandbox/Sample/11_Vcr.php @@ -0,0 +1,65 @@ +setCassettePath(__DIR__ . '/cassettes') + ->setMode(VCR::MODE_NONE); + VCR::turnOn(); + VCR::insertCassette('exchange-rates'); + } + + protected function tearDown(): void + { + VCR::eject(); + VCR::turnOff(); + parent::tearDown(); + } + + public function testFetchesRateForCurrencyPair(): void + { + $rate = (new ExchangeRatesClient())->rate(from: 'EUR', to: 'USD'); + + $this->assertGreaterThan(1.0, $rate); + } + } +} + +namespace Sample\Testo { + + use App\Rates\ExchangeRatesClient; + use Testo\Assert; + use Testo\Bridge\VCR; + use Testo\Bridge\VCR\RecordMode; + use Testo\Test; + + #[Test] + final class ExchangeRatesClientTest + { + #[VCR('exchange-rates', mode: RecordMode::None)] + public function fetchesRateForCurrencyPair(): void + { + $rate = (new ExchangeRatesClient())->rate(from: 'EUR', to: 'USD'); + + Assert::float($rate)->greaterThan(1.0); + } + } +}