HiveBrain v1.2.0
Get Started
← Back to all entries
patterncsharpMinor

Testing procedure with several nested yield statements in Unity3D

Submitted by: @import:stackexchange-codereview··
0
Viewed 0 times
unity3dwithstatementsprocedureseveralnestedtestingyield

Problem

I made a small game that does the following:

  • Displays a popup with the instructions of the game



  • Turns on the GUI



  • Start a list of trials for the player



  • For each trial:



  • It shows a symbol



  • Waits for a set amount of time



  • Show another symbol



  • Waits for the user to give an answer or for a timer to expire



  • Indicates the feedback for the answer for a set amount of time



  • Waits for a while before beginning the next trial



  • Turns off the GUI



I am going to show some Unity3d's C# code. If you don't know Unity3D's API, please stick with me, I will make it as generic as possible, and I will explain Unity3D's specificities.

The following was my original code:

```
public class Game: Component {
...

public IEnumerator PlayGame(
IGameDefinition gameDefinition,
ICourutineManager cm
)
{
CountDown answerCountdown = new CountDown(gameDefinition.answerTimemout);
Trial[] trials = GenerateTrials(gameDefinition);
PlayerAnswer playerAnswer;

yield return cm.WaitForCoroutine(_OpenInstructionsAndWait());

TurnOnGUI();

yield return cm.WaitForSeconds(gameDefinition.waitBeforeBegin);

foreach (Trial trial in trials)
{
FirstCue firstCue = GenerateFirstCue(trial, gameDefinition);
ShowCue(firstCue);

yield return cm.WaitForSeconds(gameDefinition.waitToShutOfCue1);

HideCue(firstCue);

SecondCue secondCue = GenerateSecondCue(trial, gameDefinition);
ShowCue(secondCue);

ActivateInput(ref playerAnswer);
playerAnswer = PlayerAnswer.Unanswered;
answerCountdown.Start();

yield return cm.WaitWhile(() =>
playerAnswer == PlayerAnswer.Unanswered
&&
!answerCountdown.HasFinished()
);

Result result;

if(answerCountdown.HasFinished()) {
result = Result.

Solution

Before I start, just a small disclaimer: I've only ever written C# in the context of Unity, and I'm going to be providing a good deal of controversial opinions. I do hope that I can back them up with objective reasoning.

One small point before we start proper: I don't think you should have all the abstraction you have on top of communicating to Unity (e.g. the ICoroutineManager). Unity's coroutine API isn't perfect, and I'm sure we'd all prefer being able to use async/await, but unless you're planning to switch out your engine, it's almost certainly better to use the API as Unity exposes it, rather than wrap it in an additional level of indirection that doesn't gain you much expressiveness. (In fact, unless you're running your method in another level of compatibility wrapper, I don't think it will work, as Unity's coroutine implementation expects a CustomYieldInstruction to identify how to wait, not floats. Why are you yielding floats, anyway?)

Pure functions are good, but a Coroutine is not pure.

A coroutine (in Unity) is, by definition, an iterator run for its side effect, not its yielded values. In the "common" case (running code over multiple frames), you're generating a stream of nulls.

It's easy to test a pure function: you give it arguments and check the return value. It's harder to test a function executed for its side effects, but it's still possible: you just check the state before and after to make sure that it is consistent with what you expect.

I don't understand why this change is necessary.

The testability of this method does not change between versions. If you give it mocks of the interfaces it takes, the best you can really do is assert that it calls the right methods in order, but you can tell that it does that from just looking at the code; you don't gain anything from testing obvious truths.

If the methods were only extracted to an IGameController in order to give a reason for those methods being public, then IGameController is a meaningless abstraction. Unity already offers Monobehaviour.Invoke(methodName), so you can (probably, untested) use that to test internal APIs. But that goes against the concept of unit testing: you test the external API surface.

This potentially shouldn't even be tested.

I've yet to find a good strategy for unit testing in games. You don't unit test the algorithm, you unit test the result, and that's a very ethereal concept in graphic applications. What is the goal of this method? You could say the list that you have given, but I would argue that that is not the result of the method, but rather the method by which the method achieves its goal.

A single-responsibility method does one thing, and that one thing is what you would test. In this case, the one thing is a higher level concept: present a set of trials to the user for completion.

What would I do?

It's a hard question. I don't think I have an answer, only suggestions. The way I understand unit tests is that they are written the same way that a consumer would use your code. In games, most of the code is consumed by the engine, not by other code, and as such, unit tests aren't as applicable. Unit tests should cover behaviors that could have their implementation change but should retain the same behavior.

The other way I use unit tests is as a sort of regression test. If you find a deficit in your product because of a method behaving incorrectly, write a failing unit test for it if it's a testable behavior. You then fix it and are guaranteed that the error won't return. Some interactions just aren't unit testable though, because they rely on the engine powering it.

But that said, here's how I believe I would currently design a method to present a set of trials. I will be using Unity's coroutine surface. I assumed your FirstCue and SecondCue could be merged into one conceptual Cue.

public IEnumerator PresentTrials(Trial[] trials, Settings settings)
{
    PlayerResponse? playerResponse;
    Stopwatch timer = new Stopwatch();

    yield return StartCoroutine(PresentInstructionPopup());
    EnableGUI();
    yield return new WaitForSeconds(settings.delayAfterGUIInit);

    foreach (var trial in trials)
    {
        playerResponse = null;

        Display(Cue.from(trial.first, settings));
        yield return new WaitForSeconds(settings.delayBetweenTrialCues);
        Display(Cue.from(trial.second, settings));

        ActivateInput(ref playerResponse);
        timer.StartNew();
        yield return new WaitWhile(() =>
            playerResponse == null && timer.Elapsed < settings.answerTimeout
        );
        timer.Stop();
        DisableInput();

        Display(Result.from(playerResponse));
        yield return new WaitForSeconds(settings.delayBetweenTrials);
    }

    DisableGUI();
}


Then, as building blocks which could potentially be tested, you have:

  • this.Display(Cue)



  • this.Display(Result)



  • this.EnableGUI()



  • this.DisableGUI()



  • this.Activat

Code Snippets

public IEnumerator PresentTrials(Trial[] trials, Settings settings)
{
    PlayerResponse? playerResponse;
    Stopwatch timer = new Stopwatch();

    yield return StartCoroutine(PresentInstructionPopup());
    EnableGUI();
    yield return new WaitForSeconds(settings.delayAfterGUIInit);

    foreach (var trial in trials)
    {
        playerResponse = null;

        Display(Cue.from(trial.first, settings));
        yield return new WaitForSeconds(settings.delayBetweenTrialCues);
        Display(Cue.from(trial.second, settings));

        ActivateInput(ref playerResponse);
        timer.StartNew();
        yield return new WaitWhile(() =>
            playerResponse == null && timer.Elapsed < settings.answerTimeout
        );
        timer.Stop();
        DisableInput();

        Display(Result.from(playerResponse));
        yield return new WaitForSeconds(settings.delayBetweenTrials);
    }

    DisableGUI();
}

Context

StackExchange Code Review Q#159302, answer score: 5

Revisions (0)

No revisions yet.