Testovani Repository a inject jine tridy

martin28
Člen | 40
+
0
-

Ahoj, potrebuji pomoci. Nevim zda je navrh projektu uplne idealni, ale takto byl postaven a tedka nad to pisi testy.

v testu mam

$objednavka = $this->container->getByType(\App\Model\ObjednavkaRepository::class);
$objednavka->importujSoubor( 1 );
class ObjednavkaRepository extends BaseRepository
{
    const DB_TABLE = "objednavky";
    const PRIMARY_COLUMN = "id";

    /** @var SablonyImportRepository @inject*/
    public $sablony_import;

    public function __construct(Connection $database)
    {
        parent::__construct($database);
    }

    public function importujSoubor($value)
    {
		$this->sablony_import->getItemsPolozky( xxx );
    }

chyba
Error: Call to a member function getItemsPolozky() on null

asi to zpusobuje ze to neinjectuje tu SablonyImportRepository. Jak toto vyresit?

A prosim prepisovat aplikaci, nejde!

Dekuji Martin

nightfish
Člen | 518
+
+2
-

Závislosti, které nejdou přes konstruktor, si musíš injectnout ručně.

$objednavka = $this->container->getByType(\App\Model\ObjednavkaRepository::class);
$objednavka->sablony_import = $this->container->getByType(SablonyImportRepository::class);
$objednavka->importujSoubor( 1 );
Mysteria
Člen | 797
+
+2
-

Kdybys to tam dal přes konstruktor (stejně jako Connection), tak bys tohle vůbec nemusel řešit. Jakej má vůbec smysl mít jednu závislost přes konstruktor a druhou přes inject? Chápal bych kdyby oboje bylo buď jedním nebo druhým způsobem, ale takhle to mixovat?

Polki
Člen | 553
+
-2
-

Souhlas s klukama. Udělej to jednotně.

Co ale nechápu ještě víc je, proč sakra taháš ObjednavkaRepository z DI kvůli testům? Máš testovat ObjednavkaRepository, nebo DIC? :D

Pokud ObjednavkaRepository, tak je zbytečné tahat celý DIC a kdo ví co z něj tahat a tvořit za služby, tak opravdu testuj jen tu třídu ObjednavkaRepository například takto:


class ConnectionMock extends Connection {}
class SablonyImportRepositoryMock extends SablonyImportRepository {}

$objednavkaRepo = new ObjednavkaRepository(new ConnectionMock());
$objednavkaRepo->sablony_import = new SablonyImportRepositoryMock();
$objednavkaRepo->importujSoubor( xxx );

Tím, že si vytvoříš mock ke každé té třídě, co je jako závislost docílíš toho, že se connection nebude doopravdy dotazovat do databáze a sablony_import nebude opravdu něco importovat.
Díky tomu docílíš toho, že se na pozadí neudělají side-efekty a hlavně otestuješ opravdu jen a jen třídu ObjednavkaRepository a nic jiného.

Vem si příklad, že by jsi testoval platební bránu. Na testu to je ok, jelikož se připojí na testovací rozhraní a nic ti to nestrhne. Co když se ale náhodou spustí testy nějaký blázen na produkci, kde je jako závislost nastavena produkční verze připojení naostrou platební bránu a je tam nastaven nějaký systém opakované platby? To by to najednou všem zákazníkům strhlo peníze za opakovanou platbu například, i když by nemělo. A to je průser jako prase.

Řešení je opět to, co jsem napsal:

class PayPal implements PayGate
{
    ...
    public function makeRepeatedPayment(User $user, float $price): string
	{
		// uděláme reálnou platbu nad reálnou platební bránou a vrátíme identifikátor platby...
		curl...
		$response = json_decode(curl_exec...
		if ($response->status !== ok) {thr new PaymentException()}
		return $response->data->code;
	}
    ...
}

interface PayGate
{
	/**
	 * Make payment and return payment identifier
	 * @throws PaymentException
	 */
	public function makeRepeatedPayment(User $user, float $price): string;
}

/// Stejně jako jsme udělali platební bránu uděláme i UserRepository (UserRepository je interface - děkujme Grudlovi, že to musíme vysvětlovat protože udělal Nette standard bez prefixu či suffixu - a pak máme konkrétní třídy UserRepositoryNette, UserRepositoryDoctrine, UserRepositoryFirebase atp., které konkrétně implementují získávání uživatelů podle konkrétní použité databáze/driveru atp.) no a třída User je obyčejný DataHolder

class OrderCron
{
	public function __construct(
		private PayGate $payGate,
		private UserRepository $userRepository,
	) {	}

	public function makeRepeatedPayments(): void
	{
		$users = $this->userRepository->getUsersWithRepeatedPayments();
		foreach ($users as $user) {
			try {
				$response = $this->payGate->makeRepeatedPayment($user, 50);
				$this->userRepository->confirmPayment($user, $response);
			} catch (PaymentException $e) { ... }
		}
	}
}

Tak to bychom měli vytvořenou aplikaci. Ale abych otestoval třídu OrderCron, tak se nemusím připojovat do databáze ani dělat dotaz na platební bránu. Udělám tedy toto (všechny třídy jsou teď reálně napsané, jak by vypadaly testy a ne pseudokod, jako nahoře.):

class CallCounter
{
	private int $called = 0;
	public function __construct(
		private int $callCount;
	) {}

	public function isCalledRightTimes(): bool
	{
		return $this->callCount === $this->called;
	}
}

class PayGateMock implements PayGate extends CallCounter
{
	public function makeRepeatedPayment(User $user, float $price): string
	{
		$this->called++;
		if ($user->id === 1) {
			throw new PaymentException('Lack of funds');
		}
		if ($price !== 50) {
			throw new Exception();	// Jiná exception, než se zachytává. Ideálně nějaká TestException
		}
		return '123456789AaBb+++';
	}
}

class UserRepositoryMock implements UserRepository extends CallCounter
{
	public function getUsersWithRepeatedPayments(): array
	{
		$this->called++;
		return [
			new User(id: 1, name: '', surname: ''),	// pro testy potřebuju jen id, takže ostatní položky můžu vyplnit prázdným...
			new User(id: 8, name: '', surname: ''),
		];
	}

	public function confirmPayment(User $user, string $response): void
	{
		$this->called++;	// Ideálně by každá funkce měla mít vlastní counter...
		$wantedUser = new User(id: 8, name: '', surname: '');
		// 2 rovnítka u třídy, jelikož jsou to jiné objekty, ale my chceme porovnat jen obsahy
		if ($user != $wantedUser || $response !== '123456789AaBb+++') {
			throw new Exception();	// Jiná exception, než se zachytává. Ideálně nějaká TestException
		}
	}
}

// A teď kouzla - samotné testování:
$payGate = new PayGateMock(callCount: 2);
$userRepository = new UserRepositoryMock(callCount: 2);	// 1x getUsersWithRepeatedPayments a 1x confirmPayment kvůli exceptioně z paygate. Takto ověříme, že se opravdu funkce nevolá zbytečně.

$orderCron = new OrderCron($payGate, $userRepository); // Vytvoříme reálnou instanci toho, co chceme testovat a hodíme mu tam mocky.
$orderCron->makeRepeatedPayments(); // Tímto se otestuje že to probělo ok. Pokud nevyskočí TestException, tak je vše v pořádku

// A ještě musíme ověřit, jestli se funkce volaly tak, jak měly...
assert($payGate->isCalledRightTimes());
assert($userRepository->isCalledRightTimes());

S mockama je pak testování elegantnější. Hlavně jak vidíš, tak se v mockách nedělají žádné side-efekty. Problém může nastat v tom, že psát takové mocky můžebýt zdlouhavé, a pak takové, že by i mock potřeboval testy a to je špatně a v neposlední řadě to, že třeba z Nette\Database\Explorer mock neuděláš, protože nemá nějaký globálně přístupný interface, takže by sis musel nad tím buď vytvořit nějaký adapter, fasádu, proxy atp., podle typu, kterou by jsi udělal tak, aby už šla mockovat. A to v tvém případě nejde jak píšeš.

Ale existuje jiná cesta. Někdo vymyslel mocnou knihovnu Mockery, která to vše udělá za tebe a ty se nestaráš. Moje mocky+testy by s pomocí mockery vypadaly takto:

$user1 = new User(id: 1, name: '', surname: '');
$user2 = new User(id: 8, name: '', surname: '');
// tady PayGate už nemusí být interface, ale klidně i třída se závislostmi v konstruktoru. Mockery si s tím poradí vytvořením dalších mocků pro případné konstruktorové závislosti.
$payGate = Mockery::mock(PayGate::class)
	->shouldReceive('makeRepeatedPayment')
	->with($user1, 50)
	->andThrow(PaymentException::class, 'Lack of funds', 0)
	->once()
	->shouldReceive('makeRepeatedPayment')
	->with($user2, 50)
	->andReturn('123456789AaBb+++')
	->once()
	->getMock();
$userRepository = Mockery::mock(UserRepository::class)
	->shouldReceive('getUsersWithRepeatedPayments')
	->with()
	->andReturn([
		$user1,
		$user2,
	])
	->once()
	->shouldReceive('confirmPayment')
	->with($user2, '123456789AaBb+++')
	->once()
	->getMock();

// v $payGate a $userRepository máme tedy mocky a už jen otestujeme třídu.

$orderCron = new OrderCron($payGate, $userRepository); // Vytvoříme reálnou instanci toho, co chceme testovat a hodíme mu tam mocky.
$orderCron->makeRepeatedPayments(); // Tímto se otestuje že to probělo ok. Pokud nevyskočí nějaký MockeryException, tak je vše v pořádku

// A ještě musíme ověřit, jestli se funkce volaly tolikrát, kolikrát měly:
Mockery::close();

Jak vidíš, tak s použitím knihovny Mockery se mockované třídy tvoří skoro samy, není k tomu třeba řešit závislosti a hlavně MOCKY NEDĚLAJÍ SIDE-EFEKTY a nemusíš natahovat další závislosti a tvořit celý DIC…

Tvůj dotaz by byl otestován ± takto:

$connection = Mockery::mock(Connection::class)
	->getMock();
$sablonyImportRepository = Mockery::mock(SablonyImportRepository::class)
	->shouldReceive('getItemsPolozky')
	->with('xxx')
	->once()
	->getMock();

$objednavkaRepository = new ObjednavkaRepository($connection);
$objednavkaRepository->sablony_import = $sablonyImportRepository;
$objednavkaRepository->importujSoubor('val');

Mockery::close();

Easy, clear and nice.

Editoval Polki (7. 5. 2022 8:53)