Characterizing the Game¶
Do This First...
Be sure to have completed the prior labs before starting. You can also use the solution from the repository on GitHub and do a checkout of the tag lab4-start
.
Goal¶
In this lab we'll "inject" an object into Game
that will collect all of the output into one large String. This will allow us to do some minimal characterization testing to support our major refactoring in the next lab.
We'll first replace usages of System.out.println
with a single method, which will then allow us to capture the output in tests. Then we'll refactor usages of the Scanner
so we can control the input from the tests as well. Finally, we'll write some tests!
A. Funnel Output¶
The first step is to replace all calls to System.out.println
with a call to a single method (e.g., println
). We'll know we've done this correctly if we comment out the implementation of that method, run the game, and don't see any output. If we do see output, it means we've missed something.
Search & Replace¶
The most straightforward way is to:
-
Create a static method like
void println(Object toPrint)
1 that takes an object and doesSystem.out.println
with it. -
Create a replacement for
System.out.print
(print without a new line). -
Search for
System.out.
and replace it with nothing, so it calls our new methods2. -
You may need an additional method, the compiler will tell you what's missing.
Try that first, then run the game manually and make sure everything works as before. (Did you win? 😁)
Finding Missed Output¶
Now comment out the internals of the println
/print
methods, and run the game again. Do you see any output?
Since you can't see anything, you'll have to trust that the input is being processed and do the following:
Press Enter (starts the game and deals cards)
Then type S Enter (chooses the "Stand" option)
You should see nothing (not even empty lines), except for when you type. The game should end and return to the command prompt. If you do see anything, you've missed replacing a System.out.print
or System.out.println
somewhere, so fix it and try again.
Refactoring¶
While Search & Replace is a well-known way to go, you know I'm going to have you do it again using automated refactorings. 😉
Reset to the Start
Be sure to go back to the lab4-start
tag, or otherwise get yourself back to the starting point before the Search & Replace.
-
Find a usage of
System.out
(in a static method is best) that is in astatic
method, and highlight it: -
Perform the Introduce Field3 refactoring and name it
consoleOut
❶. Be sure to select options to initialize it in the Field Declaration❷, do not make it final❸, and replace all occurrences❹. The resulting field should bestatic
, if not, you need to make sure you've selectedSystem.out
from a method that isstatic
.
As before, run the game manually and it should work as it did before the refactoring. (Did you win this time? 😊)
Finding Missed Output¶
This time we can't just comment out a method, nor replace our field with null
. Instead, we replace it with something that doesn't print any output on the console4. Replace the field assignment:
with:
- We'll see this code again in a bit!
As before, run the game manually, press Enter, S, and then Enter. You won't see any output, but it should end normally. (You'll never find out if you won or lost, sorry. 😔)
Restore System.out
Be sure to restore the field to use System.out
, we'll need it for manual testing. Later on we'll make it replaceable from tests.
B. Feeding the Scanner¶
Let's write a little test that will eventually become our Characterization Test for Game
. We'll put this test in the console
package, as that's where it will eventually live, given that it's testing console output.
package com.r2ha.blackjack.adapter.in.console;
import com.r2ha.blackjack.domain.Game;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
public class GameDisplayTest {
@Test
void gamePlays() {
// Starts the game with an empty String array for the arguments.
Game.main(new String[0]);
}
}
Predict
Before you run the test, predict what will happen.
Now run the test.
As you likely predicted, the test hangs, because the game is waiting for keyboard input when calling scanner.nextLine()
in the Game.waitForEnterFromUser()
method.
Let's make the input something that we can control from our tests.
Consolidate Scanner¶
There are two Scanner instances—both local variables—in the code. That's bad for multiple reasons: we create a new object every time we want input, but even worse, a Scanner
will consume more of its input than it uses, making it hard to control from our test (trust me, I'm saving you hours of troubleshooting right here! 😥).
Your next task is to:
- Declare a new static field to hold the scanner object
- Initialize the scanner in the first line of the
main
method, continuing to useSystem.in
. - Remove all local variables of the scanner, using the new static field
This is a slightly risky change, but we can (once again) test manually to ensure that we haven't changed any behavior.
Once you've made the changes, play a game or three (I won't tell anyone) to make sure it still works. (Aren't you glad you're not betting money? 💸)
Replace System.in
¶
Now we can provide input to the game from our test.
Add the following at the top of the GameDisplayTest
:
private final InputStream originalSystemIn = System.in;
private void provideInput(String input) {
byte[] inputBytes = input.getBytes();
ByteArrayInputStream testIn = new ByteArrayInputStream(inputBytes);
System.setIn(testIn); // reading from System.in will consume our input
}
@AfterEach
public void restoreSystemInput() {
System.setIn(originalSystemIn);
}
Then insert the following line at the top of the gamePlays()
test method:
Now, if you run the test, it should complete (and pass, since we're not asserting anything).
Test Failed?
If you see a failure and stack trace similar to this:
java.util.NoSuchElementException: No line found
at java.base/java.util.Scanner.nextLine(Scanner.java:1656)
...
It means you missed replacing one of the usages of scanner
. There should only be one place where the Scanner
is instantiated: in the top of the main
method. All other code (only two places) should use the static scanner
field we created.
C. Spying on the Output¶
Now that all of the display code is funneled into a single method, and we can control the inputs, we can add a way to redirect that output to an object that we can inspect in our tests.
Test First!¶
Let's update the test to capture the output and start asserting something useful. After we provide the input that will drive the game, let's inject a PrintStream
object5. This object will be used instead of System.out
(which is also a PrintStream
). (The way we create a PrintStream
here should ring a bell.) The new lines in the test are highlighted:
- Lines 4-5 create our output-capturing
PrintStream
- Line 6 is a new method that you'll have to implement so that
Game
uses ourPrintStream
if we tell it to, otherwise it should default toSystem.out
. - Line 10 obtains the captured output.
-
Lines 11-12 are the familiar way to start a characterization test: comparing against an empty string so that we can capture the output from the failed test run.
Use Correct
assertThat
Be sure to add the correct import for the
assertThat
, as IntelliJ IDEA sometimes suggests the wrong import. I always put this in the import section of my test classes:import static org.assertj.core.api.Assertions.*;
Implement directOutputTo
¶
The directOutputTo()
method in Game
should, as you could guess, direct the output to the specified PrintStream
instead of System.out
.
Once you've implement it, run the updated test. It should fail like this:
followed by a bunch of messy text.
D. What to Compare Against?¶
Think About This First...
What happens if we try to compare the actual output against a copy of a previous test run? (Try it.)
Will that be reliably predictable across all test runs? If not, why not?
Since we're not quite ready to fix the underlying problem (randomness) preventing predictable tests for the entire string, it's your job to figure out what we can reliably predict as well as what's important for us to "characterize".
Writing More Detailed Assertions
You can try asserting 4 cards are displayed twice (once for the initial deal, then once for the final state). This is tricky because of the ANSI escape sequences, so use Java's regex to remove it and replace them with new lines:
Writing Assertions¶
You'll want to compare against several individual strings, so you can use AssertJ's .containsIgnoringWhitespaces
method where you can specify several strings, and any whitespace (including new lines) will be ignored. For example:
Now go and write the assertions.
Success
Don't proceed until you feel like you have the text output well-covered and all tests are passing.
Now that we have a reasonable Characterization test, we can feel more comfortable about ripping the Game
class apart into the Domain (I/O-free) and Console (I/O-based) pieces. But that will have to wait until the next lab.
You're Done
That's the end of this lab. Well done!
If you want to compare your solution with mine, you can checkout from the repository on GitHub using the tag lab4-solution
.
-
We use
Object
here because theansi()
method that the game uses returns anAnsi
object. When we useSystem.out.println()
, it implicitly calls.toString()
on the object, so we want to do the same thing. ↩ -
You can also do this with the multi-caret functionality by selecting a
System.out.
that you want to replace, and then pressing Ctrl+G (Mac) or Alt+J (Win/Linux) multiple times to select the rest. Then hit Del. ↩ -
For Mac, it's Cmd+Option+F. On Windows/Linux, it's Ctrl+Alt+F. ↩
-
This is the Null Object design pattern, replacing an object with an object that has the same type, but does nothing. In this case, it does do something (stores the output internally), but we won't see it, so it's effectively doing "nothing". (If output is stored, but never seen, did it really happen? 🤔) ↩
-
Wait, don't you hate mocks and spies and such? I don't hate them, I just find them to be often used incorrectly (or too much). In this situation, we're dealing with "legacy code" that we want to change as little as possible as we add characterization tests. Once we've refactored, the injected spy might go away, or we might convert it into a "nullable", specifically an Output Tracker. ↩