PHP

Tests unitaires en PHP #2 prendre un peu plus en main PHPUnit

Découvrir PHPUnit en écrivant 6 nouveaux tests, dans un contexte objet.

→ Corrigé du Challenge : Euro 2020 en 2021

Pour démarrer (si pas encore lu) : Tests unitaires en PHP #1

Ce corrigé s’appuie sur l’organisation du code proposé dans la Sandbox PHP. De cette manière, les dossiers, fichiers et namespaces nécessaires sont prêts à être utilisés.

Je travaille ici à 2 endroits :

  • Dans le dossier /challenges/FOOTBALL_3 où je vais créer une classe puis l’utiliser dans le fichier football_3_api.php
  • Dans le dossier /phpunit/FOOTBALL_3, dans le fichier Football_3Test.php

Organisation du code

Dans ce challenge, il est question d’analyser les résultats de plusieurs matchs et de définir le classement des équipes une fois tous les matchs joués.

Pour cela, je vais m’appuyer sur un jeu de données, pour lequel je connais le résultat. Ça tombe bien, chaque challenge propose un jeu de données, et le résultat qui va avec ! Il suffit de passer l’énoncé et de cliquer sur « Afficher », dans la section « Exemple de données et déroulé ». Trouver un jeu de données pour le Challenge FOOTBALL_3.

Et en fait, quand on fait des tests unitaires, c’est souvent (voir tout le temps) ce qu’on fait. C’est-à-dire qu’on sait avant de coder ce que notre code doit retourner, ce qu’il doit accomplir. On va donc suivre ce principe.

Pour simplifier, je vais avoir 3 équipes :

  • FRA pour la France
  • BEL pour la Belgique
  • ANG pour l’Angleterre

Et 2 matchs :

  • La France va battre la Belgique : la France va gagner 3 points
  • La France et l’Angleterre vont faire match nul : la France et l’Angleterre vont gagner 1 point chacune

À la fin, j’aurais donc ce classement :

  1. France avec 4 points (3 + 1)
  2. Angleterre avec 1 point
  3. Belgique avec 0 point

Et ce jeu de données va me suffire pour écrire mes tests :

$group = ['FRA', 'BEL', 'ANG'];
$scores = ['FRA_BEL_1_0', 'ANG_FRA_2_2'];

Dans le dossier /challenges/FOOTBALL_3, je crée une classe Board dans un fichier Board.php qui contiendra ces éléments :

  • Des constantes de classe pour définir les points
  • Un attribut « scores » qui suivra les scores de chaque équipe
  • Un constructeur pour initialiser les scores en fonction du groupe des équipes
  • Une méthode match qui traitera 1 match
  • Une méthode matchs qui traitera tous les matchs
  • Une méthode getOrder qui retournera le classement des équipes dans l’ordre des points
final class Board
{
	private const POINTS_START = 0;
	private const POINTS_VICTORY = 3;
	private const POINTS_DRAW = 1;

	public array $scores = [];

	public function __construct(array $group)
	{
		
	}

	public function match(string $score): void
	{
		
	}

	public function matchs(array $scores): void
	{
		
	}

	public function getOrder(): string
	{
		return '';
	}
}

Les premiers tests avec PHPUnit

Encore une fois, écrire ses tests avant est une bonne façon de faire.

Pour tester mon constructeur, je dois m’assurer que les scores de chaque équipe présente dans le groupe sont initialisés à 0.

Ensuite, un match a 3 issues possibles :

  • L’équipe à domicile gagne aura donc 3 points
  • L’équipe à l’extérieur gagne et aura donc 3 points
  • Les 2 équipes sont à égalité et auront donc 1 point chacune

Je peux donc écrire ces 4 tests :

declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use Challenges\FOOTBALL_3\Board;

final class Football_3Test extends TestCase
{
	public function setUp(): void
	{
		parent::setUp();

		$group = ['FRA', 'BEL', 'ANG'];
		$this->Board = new Board($group);
	}

	// TES METHODES DE TEST ---------------------
	public function test_constructeur(): void
	{
		$this->assertEquals(
			$this->Board->scores,
			['FRA' => 0, 'BEL' => 0, 'ANG' => 0]
		);
	}

	public function test_match_domicile_gagne(): void
	{
		$score = 'FRA_BEL_1_0';
		$this->Board->match($score);

		$this->assertEquals(
			$this->Board->scores,
			['FRA' => 3, 'BEL' => 0, 'ANG' => 0]
		);
	}

	public function test_match_exterieur_gagne(): void
	{
		$score = 'FRA_BEL_0_4'; // Dans un univers parallèle
		$this->Board->match($score);

		$this->assertEquals(
			$this->Board->scores,
			['FRA' => 0, 'BEL' => 3, 'ANG' => 0]
		);
	}

	public function test_match_egalite(): void
	{
		$score = 'FRA_ANG_2_2';
		$this->Board->match($score);

		$this->assertEquals(
			$this->Board->scores,
			['FRA' => 1, 'BEL' => 0, 'ANG' => 1]
		);
	}
}

Un peu d’explications.

Tout d’abord, je fais référence à ma classe grâce au « use » en haut du fichier.

Dans la méthode setUp, je crée un nouveau « Board » avec le groupe de mon jeu de test.

Ensuite, j’ai mes 4 tests décrits + haut.

Maintenant, je lance mes tests avec ma console : (rappel de la commande, à la racine du projet : ./bin/vendor/phpunit –testsuite PHPUnit)

Tout est normal ici, pas d’inquiétude !

Forcément, chaque test est faux puisque mes méthodes ne sont pas encore codées.

Voici une façon d’écrire ces 2 méthodes :

final class Board
{
	private const POINTS_START = 0;
	private const POINTS_VICTORY = 3;
	private const POINTS_DRAW = 1;

	public array $scores = [];

	public function __construct(array $group)
	{
		foreach ($group as $team) {
			$this->scores[$team] = self::POINTS_START;
		}
	}

	public function match(string $score): void
	{
		[$homeTeam, $awayTeam, $homeScore, $awayScore] = explode('_', $score);

		if ($homeScore > $awayScore) {
			$this->scores[$homeTeam] += self::POINTS_VICTORY;
		} elseif ($awayScore > $homeScore) {
			$this->scores[$awayTeam] += self::POINTS_VICTORY;
		} else {
			$this->scores[$homeTeam] += self::POINTS_DRAW;
			$this->scores[$awayTeam] += self::POINTS_DRAW;
		}
	}

	public function matchs(array $scores): void
	{
		
	}

	public function getOrder(): string
	{
		return '';
	}
}

Si je relance mes tests :

Oh du vert 🙂

Comme mes tests sont bons, je peux essayer d’améliorer mon code. Peut-être que je peux me passer de ces if/else d’une façon ou d’une autre ?

Encore 2 tests avec PHPUnit

Je vais donc tester 2 choses :

  • Que les scores de mes équipes sont correctes si je traite plusieurs matchs
  • Que l’ordre du classement est le bon
public function test_matchs(): void
{
	$scores = ['FRA_BEL_1_0', 'ANG_FRA_2_2'];
	$this->Board->matchs($scores);

	$this->assertEquals(
		$this->Board->scores,
		['FRA' => 4, 'BEL' => 0, 'ANG' => 1]
	);
}

public function test_classement(): void
{
	$scores = ['FRA_BEL_1_0', 'ANG_FRA_2_2'];
	$this->Board->matchs($scores);

	$this->assertEquals(
		$this->Board->getOrder(),
		'FRAANGBEL' // FRA > ANG > BEL
	);
}

Voici une version possible pour les méthodes matchs et getOrder :

public function matchs(array $scores): void
{
	foreach ($scores as $score) {
		$this->match($score);
	}

	// En 1 ligne :
	// array_map([$this, 'match'], $scores);
}

public function getOrder(): string
{
	arsort($this->scores);
	return implode('', array_keys($this->scores));
}

Si je relance mes tests :

C’est beau le vert 🙂

Un peu d’explications pour getOrder :

  • arsort permet de trier de façon associative, c’est-à-dire en conservant les clés, de façon décroissante (reverse) selon les valeurs
  • array_keys va transformer [‘FRA’ => 4, ‘ANG’ => 1, ‘BEL’ => 0] en [‘FRA’, ‘ANG’, ‘BEL’] c’est-à-dire qu’il crée un nouveau tableau avec seulement les clés (keys) du tableau passé en paramètre
  • implode « colle » les éléments d’un tableau pour en faire une chaine de caractères. Et j’utilise un caractère vide comme liant.

Aller + loin

Volontairement un peu de redites avec le premier corrigé basé sur les tests unitaires.

On a testé le « good path », « le bon chemin », c’est-à-dire qu’on a testé que tout ce qui devait bien se passer se passe bien. C’est toujours par là qu’il faut commencer.

Pour aller + loin, on peut tester les cas limites, passer de mauvais paramètres, etc. c’est-à-dire le « wrong path », « le mauvais » chemin et donc vérifier que ce qui ne doit pas se passer ne se passe pas. Qu’est ce qui se passe si le group est vide pour le constructeur, ou si le score est incomplet, par exemple « FRA_BEL_1 ».

Pour chaque type de score (domicile, extérieur, égalité), on pourrait tester 2 ou 3 versions.

On pourrait tester le classement au fur et à mesure des matchs, pour 1, 2, 3, 5, 10 matchs.

Et certains éléments de Board devraient être private et testés donc différemment.

Pour continuer dans ce sujet des tests unitaires, tu peux découvrir Les tests unitaires avec PHP #3.

Résoudre le challenge

Voici comment on peut résoudre le challenge dans le fichier football_3_api.php :

// CODE DU CHALLENGE ------------------
$board = new Board($group);
$board->matchs($scores);

// REPONSE ----------------------------
$game->output(['data' => $board->getOrder()]);

Fichiers PHP finaux

Le code est également disponible sur Github.

Board.php

declare(strict_types=1);

namespace Challenges\FOOTBALL_3;

final class Board
{
	private const POINTS_START = 0;
	private const POINTS_VICTORY = 3;
	private const POINTS_DRAW = 1;

	public array $scores = [];

	public function __construct(array $group)
	{
		foreach ($group as $team) {
			$this->scores[$team] = self::POINTS_START;
		}
	}

	public function match(string $score): void
	{
		[$homeTeam, $awayTeam, $homeScore, $awayScore] = explode('_', $score);

		if ($homeScore > $awayScore) {
			$this->scores[$homeTeam] += self::POINTS_VICTORY;
		} elseif ($awayScore > $homeScore) {
			$this->scores[$awayTeam] += self::POINTS_VICTORY;
		} else {
			$this->scores[$homeTeam] += self::POINTS_DRAW;
			$this->scores[$awayTeam] += self::POINTS_DRAW;
		}
	}

	public function matchs(array $scores): void
	{
		foreach ($scores as $score) {
			$this->match($score);
		}

		// En 1 ligne :
		// array_map([$this, 'match'], $scores);
	}

	public function getOrder(): string
	{
		arsort($this->scores);
		return implode('', array_keys($this->scores));
	}
}

Football_3Test.php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use Challenges\FOOTBALL_3\Board;

final class Football_3Test extends TestCase
{
	public function setUp(): void
	{
		parent::setUp();

		$group = ['FRA', 'BEL', 'ANG'];
		$this->Board = new Board($group);
	}

	// TES METHODES DE TEST ---------------------
	public function test_constructeur(): void
	{
		$this->assertEquals(
			$this->Board->scores,
			['FRA' => 0, 'BEL' => 0, 'ANG' => 0]
		);
	}

	public function test_match_domicile_gagne(): void
	{
		$score = 'FRA_BEL_1_0';
		$this->Board->match($score);

		$this->assertEquals(
			$this->Board->scores,
			['FRA' => 3, 'BEL' => 0, 'ANG' => 0]
		);
	}

	public function test_match_exterieur_gagne(): void
	{
		$score = 'FRA_BEL_0_4'; // Dans un univers parallèle
		$this->Board->match($score);

		$this->assertEquals(
			$this->Board->scores,
			['FRA' => 0, 'BEL' => 3, 'ANG' => 0]
		);
	}

	public function test_match_egalite(): void
	{
		$score = 'FRA_ANG_2_2';
		$this->Board->match($score);

		$this->assertEquals(
			$this->Board->scores,
			['FRA' => 1, 'BEL' => 0, 'ANG' => 1]
		);
	}

	public function test_matchs(): void
	{
		$scores = ['FRA_BEL_1_0', 'ANG_FRA_2_2'];
		$this->Board->matchs($scores);

		$this->assertEquals(
			$this->Board->scores,
			['FRA' => 4, 'BEL' => 0, 'ANG' => 1]
		);
	}

	public function test_classement(): void
	{
		$scores = ['FRA_BEL_1_0', 'ANG_FRA_2_2'];
		$this->Board->matchs($scores);

		$this->assertEquals(
			$this->Board->getOrder(),
			'FRAANGBEL'
		);
	}
}

Qui a codé ce superbe contenu ?


Ta newsletter chaque mois

Corrigés, challenges, actualités, veille technique... aucun spam.