Comment faire TDD pour quelque chose avec de nombreuses permutations?

15

Lors de la création d'un système comme une IA, qui peut emprunter de nombreux chemins différents très rapidement, ou vraiment n'importe quel algorithme ayant plusieurs entrées différentes, le jeu de résultats possible peut contenir un grand nombre de permutations.

Quelle approche doit-on adopter pour utiliser TDD lors de la création d'un système qui produit de très nombreuses permutations de résultats différentes?

Nicole
la source
1
La qualité globale du système AI est généralement mesurée par un test de rappel de précision avec un ensemble d'entrée de référence. Ce test est à peu près à égalité avec les "tests d'intégration". Comme d'autres l'ont mentionné, cela ressemble plus à une «recherche d'algorithmes pilotée par les tests» plutôt qu'à une « conception pilotée par les tests ».
rwong
Veuillez définir ce que vous entendez par «AI». C'est un domaine d'études plus que tout type de programme particulier. Pour certaines implémentations de l'IA, vous ne pouvez généralement pas tester certains types de choses (par exemple: comportement émergent) via TDD.
Steven Evers
@SnOrfus Je veux dire au sens le plus général, rudimentaire, une machine à prendre des décisions.
Nicole

Réponses:

7

Adopter une approche plus pratique de la réponse de pdr . TDD est une question de conception logicielle plutôt que de test. Vous utilisez des tests unitaires pour vérifier votre travail au fur et à mesure.

Donc, au niveau du test unitaire, vous devez concevoir les unités afin qu'elles puissent être testées de manière complètement déterministe. Vous pouvez le faire en supprimant tout ce qui rend l'unité non déterministe (comme un générateur de nombres aléatoires) et en l'abstraction. Disons que nous avons un exemple naïf d'une méthode pour décider si un mouvement est bon ou non:

class Decider {

  public boolean decide(float input, float risk) {

      float inputRand = Math.random();
      if (inputRand > input) {
         float riskRand = Math.random();
      }
      return false;

  }

}

// The usage:
Decider d = new Decider();
d.decide(0.1337f, 0.1337f);

Cette méthode est très difficile à tester et la seule chose que vous pouvez vraiment vérifier dans les tests unitaires est ses limites ... mais cela nécessite beaucoup d'essais pour atteindre les limites. Donc, à la place, abstenons-nous de la partie aléatoire en créant une interface et une classe concrète qui encapsule la fonctionnalité:

public interface IRandom {

   public float random();

}

public class ConcreteRandom implements IRandom {

   public float random() {
      return Math.random();
   }

}

La Deciderclasse doit maintenant utiliser la classe concrète à travers son abstraction, c'est-à-dire l'interface. Cette façon de faire s'appelle l'injection de dépendance (l'exemple ci-dessous est un exemple d'injection de constructeur, mais vous pouvez aussi le faire avec un setter):

class Decider {

  IRandom irandom;

  public Decider(IRandom irandom) { // constructor injection
      this.irandom = irandom;
  }

  public boolean decide(float input, float risk) {

      float inputRand = irandom.random();
      if (inputRand > input) {
         float riskRand = irandom.random();
      }
      return false;

  }

}

// The usage:
Decider d = new Decider(new ConcreteRandom);
d.decide(0.1337f, 0.1337f);

Vous pourriez vous demander pourquoi ce "ballonnement de code" est nécessaire. Eh bien, pour commencer, vous pouvez maintenant vous moquer du comportement de la partie aléatoire de l'algorithme car le a Decidermaintenant une dépendance qui suit le IRandom"contrat". Vous pouvez utiliser un cadre de simulation pour cela, mais cet exemple est assez simple pour vous coder:

class MockedRandom() implements IRandom {

    public List<Float> floats = new ArrayList<Float>();
    int pos;

   public void addFloat(float f) {
     floats.add(f);
   }

   public float random() {
      float out = floats.get(pos);
      if (pos != floats.size()) {
         pos++;
      }
      return out;
   }

}

La meilleure partie est que cela peut remplacer complètement la mise en œuvre concrète "réelle". Le code devient facile à tester comme ceci:

@Before void setUp() {
  MockedRandom mRandom = new MockedRandom();

  Decider decider = new Decider(mRandom);
}

@Test
public void testDecisionWithLowInput_ShouldGiveFalse() {

  mRandom.addFloat(0f);

  assertFalse(decider.decide(0.1337f, 0.1337f));
}

@Test
public void testDecisionWithHighInputRandButLowRiskRand_ShouldGiveFalse() {

  mRandom.addFloat(1f);
  mRandom.addFloat(0f);

  assertFalse(decider.decide(0.1337f, 0.1337f));
}

@Test
public void testDecisionWithHighInputRandAndHighRiskRand_ShouldGiveTrue() {

  mRandom.addFloat(1f);
  mRandom.addFloat(1f);

  assertTrue(decider.decide(0.1337f, 0.1337f));
}

J'espère que cela vous donnera des idées sur la façon de concevoir votre application afin que les permutations puissent être forcées afin que vous puissiez tester tous les cas de bord et ainsi de suite.

Spoike
la source
3

Le TDD strict a tendance à se décomposer un peu pour les systèmes plus complexes, mais cela n'a pas trop d'importance en termes pratiques - une fois que vous avez dépassé la capacité d'isoler les entrées individuelles, choisissez simplement des cas de test qui offrent une couverture raisonnable et utilisez-les.

Cela nécessite une certaine connaissance de ce que la mise en œuvre va bien faire, mais c'est plus une préoccupation théorique - il est très peu probable que vous construisiez une IA qui a été spécifiée en détail par des utilisateurs non techniques. C'est dans la même catégorie que passer des tests par codage en dur aux cas de test - officiellement, le test est la spécification et la mise en œuvre est à la fois correcte et la solution la plus rapide possible, mais cela ne se produit jamais.

Tom Clarkson
la source
2

TDD n'est pas une question de test, c'est une question de conception.

Loin de s'effondrer avec la complexité, elle excelle dans ces circonstances. Cela vous amènera à considérer le problème plus important en plus petits morceaux, ce qui conduira à une meilleure conception.

Ne tentez pas de tester chaque permutation de votre algorithme. Construisez simplement test après test, écrivez le code le plus simple pour faire fonctionner le test, jusqu'à ce que vos bases soient couvertes. Vous devriez voir ce que je veux dire à propos de la résolution du problème, car vous serez encouragé à simuler des parties du problème tout en testant d'autres parties, pour vous éviter d'avoir à écrire 10 milliards de tests pour 10 milliards de permutations.

Edit: je voulais ajouter un exemple, mais je n'ai pas eu le temps plus tôt.

Prenons un algorithme de tri sur place. Nous pourrions aller de l'avant et écrire des tests qui couvrent l'extrémité supérieure du tableau, l'extrémité inférieure du tableau et toutes sortes de combinaisons étranges au milieu. Pour chacun, il faudrait construire un tableau complet d'une sorte d'objet. Cela prendrait du temps.

Ou nous pourrions aborder le problème en quatre parties:

  1. Parcourez le tableau.
  2. Comparez les éléments sélectionnés.
  3. Changer d'éléments.
  4. Coordonnez les trois ci-dessus.

Le premier est la seule partie compliquée du problème, mais en l'abstraction du reste, vous l'avez rendu beaucoup, beaucoup plus simple.

La seconde est presque certainement gérée par l'objet lui-même, au moins facultativement, dans de nombreux cadres de type statique, il y aura une interface pour montrer si cette fonctionnalité est implémentée. Vous n'avez donc pas besoin de tester cela.

Le troisième est incroyablement facile à tester.

Le quatrième ne gère que deux pointeurs, demande à la classe de parcours de déplacer les pointeurs, appelle une comparaison et, en fonction du résultat de cette comparaison, appelle les éléments à échanger. Si vous avez truqué les trois premiers problèmes, vous pouvez le tester très facilement.

Comment avons-nous mené à une meilleure conception ici? Supposons que vous ayez gardé les choses simples et mis en œuvre une sorte de bulle. Ça marche mais, quand on passe en production et qu'il faut gérer un million d'objets, c'est beaucoup trop lent. Tout ce que vous avez à faire est d'écrire une nouvelle fonctionnalité de traversée et de l'échanger. Vous n'avez pas à gérer la complexité de la gestion des trois autres problèmes.

Vous constaterez que c'est la différence entre les tests unitaires et le TDD. Le testeur d'unité dira que cela a rendu vos tests fragiles, que si vous aviez testé de simples entrées et sorties, vous n'auriez plus à écrire plus de tests pour votre nouvelle fonctionnalité. Le TDDer dira que j'ai séparé les préoccupations de manière appropriée afin que chaque classe que j'ai fasse bien une chose et une chose.

pdr
la source
1

Il n'est pas possible de tester chaque permutation d'un calcul avec de nombreuses variables. Mais ce n'est pas nouveau, cela a toujours été vrai pour tout programme au-dessus de la complexité des jouets. Le point des tests est de vérifier la propriété du calcul. Par exemple, trier une liste avec 1000 numéros demande un certain effort, mais toute solution individuelle peut être vérifiée très facilement. Maintenant, bien qu'il y en ait 1000! (classes de) entrées possibles pour ce programme et vous ne pouvez pas toutes les tester, il suffit de générer 1000 entrées au hasard et de vérifier que la sortie est bien triée. Pourquoi? Parce qu'il est presque impossible d'écrire un programme qui trie de manière fiable 1000 vecteurs générés aléatoirement sans être également correct en général (à moins que vous ne le fassiez délibérément pour manipuler certaines entrées magiques ...)

Maintenant, en général, les choses sont un peu plus compliquées. Il vraiment a des bugs été où un logiciel de messagerie ne fournirions pas des e - mails aux utilisateurs si elles ont un « f » dans leur nom d' utilisateur et le jour de la semaine est le vendredi. Mais je considère que c'est un effort inutile d'essayer d'anticiper une telle bizarrerie. Votre suite de tests devrait vous fournir une assurance constante que le système fait ce que vous attendez des entrées que vous attendez. S'il fait des choses géniales dans certains cas géniaux, vous le remarquerez assez tôt après avoir essayé le premier cas génial, puis vous pourrez écrire un test spécifiquement contre ce cas (qui couvrira généralement une classe entière de cas similaires).

Kilian Foth
la source
Étant donné que vous générez 1000 entrées au hasard, comment testez-vous ensuite les sorties? Un tel test impliquera certainement une certaine logique, qui en soi n'est pas testée. Vous testez donc le test? Comment? Le fait est que vous devez tester la logique en utilisant des transitions d'état - étant donné l'entrée X, la sortie doit être Y. Un test impliquant la logique est sujet à erreur autant que la logique qu'il teste. En termes logiques, justifier un argument par un autre argument vous met sur le chemin de régression sceptique - vous devez faire quelques affirmations. Ces affirmations sont vos tests.
Izhaki
0

Prenez les cas de bord plus une entrée aléatoire.

Pour prendre l'exemple de tri:

  • Trier quelques listes aléatoires
  • Prendre une liste déjà triée
  • Prenez une liste qui est dans l'ordre inverse
  • Prenez une liste presque triée

Si cela fonctionne rapidement pour ceux-ci, vous pouvez être sûr qu'il fonctionnera pour toutes les entrées.

Carra
la source