paint-brush
So verbessern Sie Code und vermeiden Streitigkeiten bei der Überprüfungvon@zavodnoyapl
1,821 Lesungen
1,821 Lesungen

So verbessern Sie Code und vermeiden Streitigkeiten bei der Überprüfung

von Aleksei Dovbenko23m2024/02/11
Read on Terminal Reader

Zu lang; Lesen

Unit-Tests und Faulheit: eine unumstrittene Methode zur Förderung individueller Fähigkeiten in Tech-Teams.
featured image - So verbessern Sie Code und vermeiden Streitigkeiten bei der Überprüfung
Aleksei Dovbenko HackerNoon profile picture
0-item
1-item


Es ist kein Geheimnis, dass Führungskräfte (Teamleiter, Tech-Leiter) bei der Bildung eines neuen Teams vor der Herausforderung stehen, einen einheitlichen Programmierstil zu etablieren, da alle Teammitglieder neu sind und jedes seine eigene Herangehensweise an die Organisation des Codes und die Auswahl hat Praktiken Methoden Ausübungen. Dies führt in der Regel zu langwierigen Debatten bei Codeüberprüfungen, die schließlich zu unterschiedlichen Interpretationen bekannter Praktiken wie SOLID, KISS, DRY usw. führen. Die Prinzipien hinter diesen Praktiken sind ziemlich unklar und bei ausreichender Beharrlichkeit leicht zu finden Paradoxien, bei denen eines dem anderen widerspricht. Betrachten wir zum Beispiel Einzelverantwortung und DRY.


Eine Variante der Definition des Single-Responsibility-Prinzips (das „S“ in SOLID) besagt, dass jedes Objekt eine Verantwortung haben sollte und diese Verantwortung vollständig innerhalb der Klasse gekapselt sein sollte. Das DRY-Prinzip (Don't Repeat Yourself) schlägt vor, Codeduplizierung zu vermeiden. Wenn wir jedoch ein Datenübertragungsobjekt (DTO) in unserem Code haben, das in verschiedenen Schichten/Diensten/Modulen verwendet werden kann, welchen dieser Prinzipien sollten wir dann folgen? Zweifellos befassen sich viele Programmierbücher mit ähnlichen Situationen und stellen in der Regel fest, dass es sich nicht um eine Duplizierung handelt, wenn wir es mit verschiedenen Objekten/Funktionen mit denselben Eigenschaften und derselben Logik zu tun haben, die jedoch zu unterschiedlichen Domänen gehören. Wie kann man jedoch beweisen, dass diese Objekte zu unterschiedlichen Domänen gehören SOLLTEN, und, was am wichtigsten ist, ist der Leiter bereit (und zuversichtlich), diese Aussage zu behaupten und zu beweisen?

 One frequently practiced approach is making categorical statements like "This is our way/It's the leader's word and we take it for granted" and similar authoritative declarations that emphasize the authority and expertise of the person who came up with these rules. This approach undoubtedly succeeds when dealing with an established team and a project with an existing codebase upon which development continues. But what should be done when the team is new, and the project has just begun? Appeals to authority may not work, as the Team/Tech Leader has not yet established their authority, and each team member believes that their knowledge and approach will be the optimal solution for the future project.


Dieser Artikel schlägt einen Ansatz vor, der es ermöglicht, die meisten dieser Konfliktsituationen zu vermeiden. Darüber hinaus wird jeder Entwickler in der Praxis (ohne Einwände des Leiters) verstehen, was er falsch macht und wie er es verbessern kann.


Lassen Sie uns zunächst einige zusätzliche Bedingungen und Definitionen einführen:

  1. Zum Zeitpunkt der Einreichung zur Prüfung gilt die Aufgabe als abgeschlossen und kann bei bestandener Prüfung ohne Änderungen freigegeben werden. Mit anderen Worten: Wir berücksichtigen nicht die Möglichkeit vorgeplanter Änderungen/Ergänzungen im Code.

  2. Das Team besteht aus gleichermaßen erfahrenen und qualifizierten Spezialisten, die bei der Umsetzung der Aufgaben keine Herausforderungen meistern; Die einzige Diskrepanz liegt in ihren Ansätzen.

  3. Der Codestil ist konsistent und wird von Codeprüfern überprüft.

  4. Die Entwicklungszeit ist nicht kritisch, zumindest weniger kritisch als die Zuverlässigkeit des Produkts.


    Wir werden später auf die Notwendigkeit der ersten Bedingung eingehen, obwohl sie für sich genommen ziemlich offensichtlich ist, da es unlogisch ist, eine unvollendete Aufgabe zur Überprüfung einzureichen. Mit der zweiten Bedingung stellen wir sicher, dass jedes Teammitglied keine Probleme mit der Auswahl eines Algorithmus und der Umsetzung der zugewiesenen Aufgabe hat. In der dritten Bedingung gehen wir davon aus, dass das Team einem bestimmten Stil (PSR) folgt und Fragen wie „Was ist besser, CamelCase oder Snake_case“ nicht auftauchen. Und die letzte Bedingung sieht in dieser Arbeit vor, Änderungen im Aufwand für die Aufgabenerledigung nicht zu berechnen.

Unit-Tests

Vielen Lesern ist bewusst, dass Unit-Tests die Codequalität verbessern. Typischerweise wird im Anschluss daran die Methode der testgetriebenen Entwicklung (TDD) als Beispiel genannt, die zwar die Codequalität verbessert, aber in der Praxis relativ selten angewendet wird, da das Schreiben von Tests vor der Implementierung ein hohes Maß an Programmierkenntnissen erfordert.


Wie können Unit-Tests dazu beitragen, den Code zu verbessern, ohne sich auf die zuvor erwähnten bekannten Praktiken zu verlassen? Erinnern wir uns zunächst daran, dass Unit-Tests angewendet werden, um eine bestimmte Methode/ein bestimmtes Modul/eine bestimmte Klasse zu testen, wobei Scheinobjekte/-module als Abhängigkeiten verwendet werden.


Gemäß der ersten Bedingung sollte die Aufgabe zum Zeitpunkt der Einreichung zur Überprüfung als abgeschlossen gelten. Lassen Sie uns daher eine Definition dessen einführen, was wir als abgeschlossene Aufgabe betrachten. Eine Aufgabe gilt erst dann als erledigt, wenn sie alle unten aufgeführten Bedingungen erfüllt:

  • Die Anforderungen der übertragenen Aufgabe werden erfüllt.

  • Der gesamte neue Code muss durch Komponententests abgedeckt werden, einschließlich verschiedener algorithmischer Bedingungen im Programm.

  • Der neue Code beeinträchtigt keine bestehenden Tests.

    Da wir unbegrenzt Zeit haben, neue Tests zu schreiben und alte zu warten (Bedingung 4) und jeder Entwickler diese Tests schreiben und die Aufgabenanforderungen erfüllen kann (Bedingung 2), können wir davon ausgehen, dass jede Aufgabe potenziell abgeschlossen werden kann. Da wir nun die Definition einer abgeschlossenen Aufgabe eingeführt haben, können wir Bedingung 1 rechtfertigen: Der Code kann nicht zur Überprüfung eingereicht werden, wenn er nicht durch Tests abgedeckt wird; andernfalls wird der Code ohne Überprüfung abgelehnt. Daher weiß ein Entwickler, dass das Beheben von Codeproblemen nach Feedback das Beheben von Tests erfordert. Dieser scheinbar unbedeutende Punkt wird zu einer grundlegenden Triebkraft für das Schreiben von gutem Code.


    Betrachten wir das folgende Codebeispiel (in diesem Artikel wird die PHP-Sprache als Beispiele verwendet, es kann sich jedoch um jede C-ähnliche Sprache mit Unterstützung für das objektorientierte Programmierparadigma handeln):


 class SomeFactory { public function __construct( private readonly ARepository $aRepository, ) { } /** * @throws ErrorException */ public function createByParameters(ObjectType $type, array $parameters): ObjectE|ObjectD|ObjectA|ObjectB|ObjectC { switch ($type) { case ObjectType::A: if (!array_key_exists('id', $parameters) || !is_int($parameters['id'])) { throw new ErrorException('Some message'); } $aEntity = $this->aRepository->findById($parameters['id']); $data = []; if ($aEntity !== null) { $data = $aEntity->getSomeParams(); } if (count($data) === 0) { if (array_key_exists('default', $parameters) && is_array($parameters['default'])) { $data = $parameters['default']; } else { throw new ErrorException('Some message'); } } return new ObjectA($data); case ObjectType::B: // some code return new ObjectB($parameters); case ObjectType::C: // some code return new ObjectC($parameters); case ObjectType::D: // some code return new ObjectD($parameters); case ObjectType::E: // some code return new ObjectE($parameters); } throw new RuntimeException('some message'); } }


Hier haben wir absichtlich gegen alle Praktiken verstoßen, um die Wirksamkeit des vorgeschlagenen Ansatzes zu demonstrieren. Beachten Sie jedoch, dass der vorgestellte Algorithmus funktionsfähig ist; Je nach Typ wird eine Entität mit bestimmten Parametern erstellt. Dennoch besteht unsere Hauptaufgabe darin, sicherzustellen, dass dieser Code nicht in die Überprüfungsphase gelangt und den Entwickler dazu veranlasst, ihn unabhängig zu verbessern. Gemäß Bedingung 1 müssen wir Tests schreiben, um den Code zur Überprüfung einzureichen. Schreiben wir einen solchen Test:


 class SomeFactoryTest extends TestCase { public function testCreateByParametersReturnsObjectAWithDefaultMethods(): void { $someFactory = new SomeFactory( $aRepository = $this->createMock(ARepository::class), ); $parameters = [ 'id' => $id = 5, 'default' => ['someData'], ]; $aRepository->expects($this->once()) ->method('findById') ->with($id) ->willReturn(null); $actualResult = $someFactory->createByParameters(ObjectType::A, $parameters); $this->assertInstanceOf(ObjectA::class, $actualResult); // additional checkers for $actualResult } }


Es stellte sich als recht einfach heraus, aber dies ist nur einer der notwendigen acht Tests für einen der fünf Typen. Nachdem alle Tests geschrieben wurden, kann jegliches Feedback während der Überprüfung, das Änderungen erfordert, diese Tests zum Scheitern bringen und der Entwickler muss sie neu schreiben oder anpassen. Das Hinzufügen einer neuen Abhängigkeit (z. B. eines Loggers) führt beispielsweise in allen Tests zu Änderungen an der Factory-Initialisierung:


 $someFactory = new SomeFactory( $aRepository = $this->createMock(ARepository::class), $this->createMock(LoggerInterface::class) );


Beachten Sie, dass die Kosten für einen Kommentar gestiegen sind: Wenn das Hinzufügen/Ändern einer Abhängigkeit zuvor nur Änderungen an der SomeFactory Klasse erforderte, müssen jetzt auch alle Tests (die mehr als 40 sein können) geändert werden. Natürlich möchte ein Entwickler nach mehreren Iterationen solcher Änderungen den Aufwand minimieren, der für die Bearbeitung von Feedback erforderlich ist. Wie kann das gemacht werden? Die Antwort liegt auf der Hand: Isolieren Sie die Entitätserstellungslogik für jeden Typ in einer separaten Klasse. Bitte beachten Sie, dass wir uns nicht auf SOLID/DRY-Prinzipien usw. verlassen und uns nicht auf abstrakte Diskussionen über Codelesbarkeit und Debugging einlassen, da jedes dieser Argumente umstritten sein kann. Wir vereinfachen lediglich das Schreiben von Tests, und es gibt für einen Entwickler keine Gegenargumente dagegen.


Nach der Änderung werden wir 5 Fabriken für jeden Typ haben ( ObjectType::A , ObjectType::B , ObjectType::C , ObjectType::D , ObjectType::E ). Unten finden Sie ein Beispiel für die Factory für ObjectType::A (FactoryA):

 class FactoryA { public function __construct( private readonly ARepository $aRepository, ) { } public function createByParameters(array $parameters): ObjectA { if (!array_key_exists('id', $parameters) || !is_int($parameters['id'])) { throw new ErrorException('Some message'); } $aEntity = $this->aRepository->findById($parameters['id']); $data = []; if ($aEntity !== null) { $data = $aEntity->getSomeParams(); } if (count($data) === 0) { if (array_key_exists('default', $parameters) && is_array($parameters['default'])) { // 6 7 $data = $parameters['default']; } else { throw new ErrorException('Some message'); } } return new ObjectA($data); } }


Und die allgemeine Fabrik wird so aussehen:


 class SomeFactory { public function __construct( private readonly FactoryA $factoryA, private readonly FactoryB $factoryB, private readonly FactoryC $factoryC, private readonly FactoryD $factoryD, private readonly FactoryE $factoryE, ) { } /** * @throws ErrorException */ public function createByParameters(ObjectType $type, array $parameters): ObjectE|ObjectD|ObjectA|ObjectB|ObjectC { switch ($type) { case ObjectType::A: return $this->factoryA->createByParameters($parameters); case ObjectType::B: return $this->factoryB->createByParameters($parameters); case ObjectType::C: return $this->factoryC->createByParameters($parameters); case ObjectType::D: return $this->factoryD->createByParameters($parameters); case ObjectType::E: return $this->factoryE->createByParameters($parameters); } throw new RuntimeException('some message'); } }


Wie wir sehen können, hat sich der Gesamtcode erhöht. Schauen wir uns die Tests für FactoryA und den modifizierten Test für SomeFactory an.


 class FactoryATest extends TestCase { public function testCreateByParametersReturnsObjectAWithDefaultMethods(): void { $factoryA = new FactoryA( $aRepository = $this->createMock(ARepository::class), ); $parameters = [ 'id' => $id = 5, 'default' => ['someData'], ]; $aRepository->expects($this->once()) ->method('findById') ->with($id) ->willReturn(null); $actualResult = $factoryA->createByParameters($parameters); $this->assertInstanceOf(ObjectA::class, $actualResult); // additional checkers for $actualResult } }


 class SomeFactoryTest extends TestCase { public function testCreateByParametersReturnsObjectA(): void { $someFactory = new SomeFactory( $factoryA = $this->createMock(FactoryA::class), $this->createMock(FactoryB::class), $this->createMock(FactoryC::class), $this->createMock(FactoryD::class), $this->createMock(FactoryE::class), ); $parameters = ['someParameters']; $factoryA->expects($this->once()) ->method('createByParameters') ->with($parameters) ->willReturn($objectA = $this->createMock(ObjectA::class)); $this->assertSame($objectA, $someFactory->createByParameters(ObjectType::A, $parameters)); } // the same test for another types and fabrics }


Die Gesamtzahl der Tests erhöhte sich um 5 (Anzahl der möglichen Typen), während die Anzahl der Tests für Fabriken gleich blieb. Was macht diesen Code also besser? Der Hauptvorteil liegt in der Reduzierung des Aufwands für Korrekturen nach einem Code-Review. Tatsächlich sind beim Ändern von Abhängigkeiten in FactoryA nur die Tests für FactoryA betroffen.


Zugegebenermaßen sieht der Code bereits besser aus, und wir haben uns, vielleicht unbeabsichtigt, teilweise an das Prinzip der Einzelverantwortung gehalten. Ist das das Ende? Wie bereits erwähnt, müssen wir noch 5 Tests für jede Entität schreiben. Darüber hinaus müssten wir die Fabriken endlos als Argumente für diesen Dienst an den Konstruktor übergeben, und die Einführung eines neuen Typs (oder das Entfernen eines alten) würde zu Änderungen in allen Tests (obwohl es jetzt nur noch 5 sind) für SomeFactory führen. Daher besteht eine logische Lösung, die die meisten Entwickler wahrscheinlich sehen werden, darin, eine Registrierung zu erstellen (insbesondere, wenn es native Unterstützung für die Klassenregistrierung nach Schnittstelle gibt) und Schnittstellen für DTOs und Fabriken zu deklarieren, wie zum Beispiel:


 interface ObjectInterface { } class ObjectA implements ObjectInterface { // some logic }


 interface FactoryInterface { public function createByParameters(array $parameters): ObjectInterface; public static function getType(): ObjectType; }


 class FactoryB implements FactoryInterface { public static function getType(): ObjectType { return ObjectType::B; } public function createByParameters(array $parameters): ObjectB { // some logic return new ObjectB($parameters); } }


Lassen Sie uns die Wahl hervorheben, die getType Methode als statisch zu definieren. In der aktuellen Implementierung gibt es keinen Unterschied, ob diese Methode statisch oder dynamisch ist. Wenn wir jedoch anfangen, einen Test für diese Methode zu schreiben (egal wie absurd diese Idee auch erscheinen mag), stellen wir möglicherweise fest, dass der Test im Fall einer dynamischen Methode wie folgt aussehen würde:


 public function testGetTypeReturnsTypeA(): void { $mock = $this->getMockBuilder(FactoryA::class) ->disableOriginalConstructor() ->onlyMethods([]) ->getMock(); $this->assertSame($mock->getType(), ObjectType::A); }


Bei einer statischen Methode würde es hingegen viel kürzer aussehen:


 public function testGetTypeReturnsTypeA(): void { $this->assertSame(FactoryA::getType(), ObjectType::A); }


Dank unserer Faulheit haben wir (vielleicht unwissentlich) die richtige Lösung gewählt und verhindert, dass die getType Methode möglicherweise vom Status des FactoryB Klassenobjekts abhängt.


Schauen wir uns den Registrierungscode an:


 class SomeRegistry { /** @var array<int, FactoryInterface> */ private readonly array $factories; /** * @param FactoryInterface[] $factories */ public function __construct(array $factories) { $mappedFactory = []; foreach ($factories as $factory) { if (array_key_exists($factory::getType()->value, $mappedFactory)) { throw new RuntimeException('Duplicate message'); } $mappedFactory[$factory::getType()->value] = $factory; } $this->factories = $mappedFactory; } public function createByParams(ObjectType $type, array $parameters): ObjectInterface { $factory = $this->factories[$type->value] ?? null; if ($factory === null) { throw new RuntimeException('Not found exception'); } return $factory->createByParameters($parameters); } }

Wie wir sehen können, müssen wir drei Tests schreiben: 1) einen Test auf Duplizierung, 2) einen Test, wenn die Fabrik nicht gefunden wird, und 3) einen Test, wenn die Fabrik gefunden wird. Die SomeFactory Klasse sieht nun wie eine Proxy-Methode aus und kann daher entfernt werden.


 class SomeFactory { public function __construct( private readonly SomeRegistry $someRegistry, ) { } public function createByParameters(ObjectType $type, array $parameters): ObjectInterface { return $this->someRegistry->createByParams($type, $parameters); } }


Abgesehen von der Reduzierung der Anzahl der Tests (von 5 auf 3) führt das Hinzufügen/Entfernen einer neuen Fabrik nicht zu Änderungen an alten Tests (vorausgesetzt, die Registrierung neuer Fabriken erfolgt nativ und in das Framework integriert).


Um unsere Fortschritte zusammenzufassen: Auf der Suche nach einer Lösung zur Reduzierung der Kosten für die Bearbeitung von Feedback nach einer Codeüberprüfung haben wir die Generierung von Objekten basierend auf Typen komplett überarbeitet. Unser Kodex folgt nun den Prinzipien „Einzelverantwortung“ und „Offen/Geschlossen“ (das „S“ und „O“ im Akronym SOLID), auch wenn wir sie nirgends explizit erwähnt haben.


Als Nächstes machen wir die Aufgabe komplexer und führen dieselbe Aufgabe mit weniger offensichtlichen Änderungen im Code aus. Schauen wir uns den Code in der FactoryA Klasse an:


 class FactoryA implements FactoryInterface { public function __construct( private readonly ARepository $aRepository, ) { } public static function getType(): ObjectType { return ObjectType::A; } public function createByParameters(array $parameters): ObjectA { if (!array_key_exists('id', $parameters) || !is_int($parameters['id'])) { throw new ErrorException('Some message'); } $aEntity = $this->aRepository->findById($parameters['id']); $data = []; if ($aEntity !== null) { $data = $aEntity->getSomeParams(); } if (count($data) === 0) { if (array_key_exists('default', $parameters) && is_array($parameters['default'])) { $data = $parameters['default']; } else { throw new ErrorException('Some message'); } } return new ObjectA($data); } }


Können wir das Schreiben von Tests für diesen Code vereinfachen? Lassen Sie uns den ersten if-Block aufschlüsseln:


 if (!array_key_exists('id', $parameters) || !is_int($parameters['id'])) { throw new ErrorException('Some message'); }


Versuchen wir es mit Tests abzudecken:


 public function testCreateByParametersThrowsErrorExceptionWhenParameterIdDoesntExist(): void { $this->expectException(ErrorException::class); $factoryA = new FactoryA( $this->createMock(ARepository::class), ); $factoryA->createByParameters([]); } public function testCreateByParametersThrowsErrorExceptionWhenParameterIdNotInt(): void { $this->expectException(ErrorException::class); $factoryA = new FactoryA( $this->createMock(ARepository::class), ); $factoryA->createByParameters(['id' => 'test']); }


Wenn die Existenzfrage leicht abgedeckt werden kann, birgt der Typtest viele Fallstricke. In diesem Test haben wir einen String übergeben, aber was ist mit anderen Typen? Wird eine große Zahl als Ganzzahl oder Gleitkommazahl betrachtet (in PHP gibt beispielsweise 10 hoch 100 eine kurze Darstellung wie 1,0E+100 des Typs Gleitkomma zurück)? Sie könnten einen DataProvider für alle möglichen Szenarien schreiben oder die Validierungslogik in eine separate Klasse extrahieren und so etwas erhalten wie:


 class FactoryA implements FactoryInterface { public function __construct( private readonly ARepository $aRepository, private readonly ExtractorFactory $extractorFactory ) { } public static function getType(): ObjectType { return ObjectType::A; } public function createByParameters(array $parameters): ObjectA { $extractor = $this->extractorFactory->createByArray($parameters); try { $id = $extractor->getIntByKey('id'); } catch (ExtractorException $extractorException) { throw new ErrorException('Some message', previous: $extractorException); } $aEntity = $this->aRepository->findById($id); $data = []; if ($aEntity !== null) { $data = $aEntity->getSomeParams(); } if (count($data) === 0) { if (array_key_exists('default', $parameters) && is_array($parameters['default'])) { $data = $parameters['default']; } else { throw new ErrorException('Some message'); } } return new ObjectA($data); } }


Einerseits haben wir eine neue Abhängigkeit hinzugefügt, vielleicht mussten wir sie sogar erstellen. Aber im Gegenzug müssen wir uns in allen anderen Fabriken über solche Probleme keine Sorgen machen. Der Test in der aktuellen Fabrik ist nur einer und deckt alle möglichen Variationen des id Parameters ab:


 public function testCreateByParametersThrowsErrorExceptionWhenParameterIdDoesntExist(): void { $this->expectException(ErrorException::class); $factoryA = new FactoryA( $this->createMock(ARepository::class), $extractorFactory = $this->createMock(ExtractorFactory::class), ); $parameters = ['someParameters']; $extractorFactory->expects($this->once()) ->method('createByArray') ->with($parameters) ->willReturn($extractor = $this->createMock(Extractor::class)); $extractor->expects($this->once()) ->method('getIntByKey') ->with('id') ->willThrowException($this->createMock(ExtractorException::class)); $factoryA->createByParameters($parameters); }


Schauen wir uns den nächsten Codeblock an, nämlich:


 $aEntity = $this->aRepository->findById($id); $data = []; if ($aEntity !== null) { $data = $aEntity->getSomeParams(); } if (count($data) === 0) { // next code


In diesem Block wird die Methode der Abhängigkeit aRepository ( findById ) aufgerufen, die mit der Methode getSomeParams entweder null oder eine Entität zurückgibt. Die getSomeParams Methode wiederum gibt ein Datenarray zurück.


Wie wir sehen können, wird die Variable $aEntity nur zum Aufruf der getSomeParams Methode benötigt. Warum also nicht das Ergebnis von getSomeParams direkt abrufen, wenn es existiert, und ein leeres Array, wenn es nicht existiert?


 $data = $this->aRepository->findSomeParamsById($id); if (count($data) === 0) {


Vergleichen wir die Tests vorher und nachher. Vor den Änderungen hatten wir drei mögliche Verhaltensweisen: 1) wenn die Entität gefunden wurde und getSomeParams ein nicht leeres Datenarray zurückgibt, 2) wenn die Entität gefunden wurde und getSomeParams ein leeres Datenarray zurückgab, 3) wenn die Entität wurde nicht gefunden.


 // case 1 $aRepository->expects($this->once()) ->method('findById') ->with($id) ->willReturn($this->createConfiguredMock(SomeEntity::class, [ 'getSomeParams' => ['not empty params'] ])); // case 2 $aRepository->expects($this->once()) ->method('findById') ->with($id) ->willReturn($this->createConfiguredMock(SomeEntity::class, [ 'getSomeParams' => [] ])); // case 3 $aRepository->expects($this->once()) ->method('findById') ->with($id) ->willReturn(null);


Im geänderten Code gibt es nur zwei mögliche Szenarien: findSomeParamsById gibt ein leeres Array zurück oder nicht.


 // case 1 $aRepository->expects($this->once()) ->method('findSomeParamsById') ->with($id) ->willReturn([]); // case 2 $aRepository->expects($this->once()) ->method('findSomeParamsById') ->with($id) ->willReturn(['not empty params']);


Zusätzlich zur Reduzierung der Anzahl der Tests haben wir $this->createConfiguredMock(SomeEntity::class, [..] entfernt.
Schauen wir uns als nächstes den Block an:


 if (count($data) === 0) { if (array_key_exists('default', $parameters) && is_array($parameters['default'])) { $data = $parameters['default']; } else { throw new ErrorException('Some message'); } }


Da wir bereits über eine Klasse verfügen, die Daten des erforderlichen Typs extrahieren kann, können wir sie verwenden und die Prüfungen aus dem Werkscode entfernen:


 if (count($data) === 0) { try { $data = $extractor->getArrayByKey('default'); } catch (ExtractorException $extractorException) { throw new ErrorException('Some message', previous: $extractorException); } }


Am Ende erhalten wir eine Klasse wie:


 class FactoryA implements FactoryInterface { public function __construct( private readonly ARepository $aRepository, private readonly ExtractorFactory $extractorFactory ) { } public static function getType(): ObjectType { return ObjectType::A; } public function createByParameters(array $parameters): ObjectA { $extractor = $this->extractorFactory->createByArray($parameters); try { $id = $extractor->getIntByKey('id'); } catch (ExtractorException $extractorException) { throw new ErrorException('Some message', previous: $extractorException); } $data = $this->aRepository->findSomeParamsById($id); if (count($data) === 0) { try { $data = $extractor->getArrayByKey('default'); } catch (ExtractorException $extractorException) { throw new ErrorException('Some message', previous: $extractorException); } } return new ObjectA($data); } }


Die Methode createByParameters verfügt nur über 4 Tests, nämlich:

  • ein Test für die erste Ausnahme ( getIntByKey )
  • ein Test, bei dem findSomeParamsById ein nicht leeres Ergebnis zurückgegeben hat
  • ein Test, bei dem findSomeParamsById ein leeres Ergebnis zurückgegeben hat und die zweite Ausnahme ( getArrayByKey ) ausgelöst wurde
  • ein Test, bei dem findSomeParamsById ein leeres Ergebnis zurückgab und ObjectA mit Werten aus dem default erstellt wurde

Wenn die Aufgabenanforderungen dies jedoch zulassen und ErrorException durch ExtractorException, wird der Code sogar noch kürzer:


 class FactoryA implements FactoryInterface { public function __construct( private readonly ARepository $aRepository, private readonly ExtractorFactory $extractorFactory ) { } public static function getType(): ObjectType { return ObjectType::A; } /** * @throws ExtractorException */ public function createByParameters(array $parameters): ObjectA { $extractor = $this->extractorFactory->createByArray($parameters); $id = $extractor->getIntByKey('id'); $data = $this->aRepository->findSomeParamsById($id); if (count($data) === 0) { $data = $extractor->getArrayByKey('default'); } return new ObjectA($data); } }


Und es wird nur zwei Tests geben:

  • ein Test, bei dem findSomeParamsById ein nicht leeres Ergebnis zurückgegeben hat

  • ein Test, bei dem findSomeParamsById ein leeres Ergebnis zurückgab und ObjectA mit Werten aus dem default erstellt wurde


Fassen wir die geleistete Arbeit zusammen.


Anfangs hatten wir schlecht geschriebenen Code, der getestet werden musste. Da jeder Entwickler Vertrauen in seinen Code hat (bis etwas mit einem Fehler abstürzt), ist das Schreiben von Tests dafür eine lange und eintönige Aufgabe, die niemand mag. Die einzige Möglichkeit, weniger Tests zu schreiben, besteht darin, den Code zu vereinfachen, der von diesen Tests abgedeckt werden muss. Letztendlich verbessert der Entwickler durch die Vereinfachung und Reduzierung der Anzahl der Tests den Code, ohne unbedingt bestimmte theoretische Praktiken zu befolgen.