I’ve been writing a card game and I planned to make it playable both on the web and in a CLI. For the CLI version, I wanted to test that the program was printing the right things to stdout at the right times.
There’s a point in the game when players can swap their cards for new ones. The function offerCardSwap does the following:
Ask the player if they want to swap any of their cards
Collect their answer (yes or no)
Ask them to retry if the answer is invalid
Assume the answer is “no” if the player doesn’t respond in time
The goal is to test offerCardSwap by feeding it various inputs, just as a real player would.
Let’s walk through how to make offerCardSwap testable. Here’s the first version of the code, which is tricky to test:
funcofferCardSwap()bool{timeout:=time.Duration(30*time.Second)message:="Would you like to swap any of your cards? [y/n]"retryMessage:="Invalid input"inputChan:=make(chanbool)gofunc(inputChanchanbool){reader:=bufio.NewScanner(os.Stdin)varvalidResponse,responseboolfor!validResponse{fmt.Println(message)reader.Scan()switchuserInput{case"Y","y":response,validResponse:=true,truecase"N","n":validResponse:=truedefault:fmt.Println(retryMessage)}}inputChan<-response}(inputChan)select{casechoice:=<-input:returnchoicecase<-time.After(timeout):returnfalse}}
The function does the following:
Kicks off a goroutine that prints a message to stdout and waits for input from stdin
Checks the input is valid and asks the player to retry if it’s not
If a valid response is received, the for loop breaks and the response is sent on the inputChan channel
The select statement returns either the value from inputChan or a default value after the timeout elapses
If you called offerCardSwap in a test suite, you would see the message print to the command line, which you can’t test. The tests would also hang for the length of the timeout, which is 30 seconds. Not ideal.
To make offerCardSwap testable, these problems must be solved:
In tests, divert the messages to the player to somewhere other than stdin and simulate their response from somewhere other than stdout
Continue to use real stdin and stdout in the main application
Prevent tests from hanging for 30 seconds
These can be solved with dependency injection, which allows us to choose what to pass in at runtime.
Capturing data sent to stdout
We want the output to go to stdout in the application, but not in tests.
We replace fmt.Println(message) with fmt.Fprint(os.Stdout, message), which has the exact same behaviour. The difference is that os.Stdout is passed in explicitly, which allows us to swap it for something else when testing.
Same for fmt.Println(retryMessage), which becomes fmt.Fprint(os.Stdout, retryMessage)
// somewhere in the application
shouldSwapCards:=offerCardSwap(os.Stdin)
Since fmt.Fprint takes anything that satisfies the io.Writer interface, we can inject *bytes.Buffer. Messages sent to the player will end up in the buffer, which we can inspect.
Now it’s possible to test the message that offerCardSwap displays to the player:
1
2
3
4
5
6
7
8
9
10
11
12
13
funcTestOfferCardSwap(t*testing.T){t.Run("player sees message",func(t*testing.T){notStdout:=&bytes.Buffer{}offerCardSwap(notStdout)got:=notStdout.String()want:="Would you like to swap any of your cards? [y/n]"ifgot!=want{t.Errorf("got %s, want %s",got,want)}})}
Simulating input from stdin
bufio.NewScanner is responsible for reading in user input. To pass in os.Stdin explicitly, we define an io.Writer parameter.
Waiting 30 seconds is fine for production, but we don’t want to wait that long in tests. Let’s inject that too.
1
2
3
funcofferCardSwap(rio.Reader,wio.Writer,inputTimeouttime.Duration){// changed
// rest of code ...
}
1
2
3
4
// somewhere in the application
constinputTimeout=time.Duration(30*time.Second)shouldSwapCards:=offerCardSwap(os.Stdin,os.Stdout,inputTimeout)
This allows us to define a shorter duration in the tests:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
funcTestOfferCardSwap(t*testing.T){testTimeout:=time.Duration(100*time.Millisecond)t.Run("player sees message",func(t*testing.T){notStdout:=&bytes.Buffer{}notStdin:=strings.NewReader("")offerCardSwap(notStdIn,notStdout,testTimeout)// changed
got:=notStdout.String()want:="Would you like to swap any of your cards? [y/n]"ifgot!=want{t.Errorf("got %s, want %s",got,want)}})}
Tidy up
Almost there. Let’s define message and retryMessage outside of offerCardSwap and inject those as well. Easier to maintain if we want to change the text in future.
1
2
3
4
5
6
7
8
// somewhere in the application
const(message="Would you like to swap any of your cards? [y/n]"retryMessage="Invalid input"inputTimeout=time.Duration(30*time.Second))shouldSwapCards:=offerCardSwap(os.Stdin,os.Stdout,inputTimeout,message,retryMessage)