Tightening Our Assertions
Part 4 of the Predictive Test-Driven Development series: looking at the precision of assertions, as well as taking smaller steps when doing TDD.
Specifying, or Over-specifying?
Last time, we were test-driving our Blackjack game. We got all the tests to pass, but there were questions around the precision of our assertion. Here’s the test we ended with:
@Test
public void playerWithTwoCardsDrawsCardResultsInThreeCards() {
Game game = new Game();
game.initialDeal();
game.playerDrawCardFromDeck();
assertThat(game.playerCards())
.hasSize(3);
}
Alternative Solution
Last time, our solution:
public void playerDrawCardFromDeck() {
playerHand.add(deck.draw());
}
was copied from the implementation in the initialDeal()
method:
public void initialDeal() {
// player gets card first due to rules of Blackjack
playerHand.drawCardFrom(deck);
dealerHand.drawCardFrom(deck);
// everyone gets two cards, so deal another round
playerHand.drawCardFrom(deck);
dealerHand.drawCardFrom(deck);
}
What if we didn’t already have a copy-and-paste solution? What would’ve been the least effort implementation? How about:
public void playerDrawCardFromDeck() {
playerHand.add(null);
}
That would still get the test to pass, as there would be 3 entries in the player’s list of cards. How? Well, null
counts as a card![1] This certainly isn’t what we want in our game, though. Perhaps we’re missing some assertions? Let’s look back at our original specification:
Given a PLAYER with a HAND containing 2 CARDs,
When the PLAYER DRAWs a CARD from the DECK,
Then the HAND should have 3 CARDs.
The highlighted part of “from the DECK” is something that we’re not asserting. We’re also not ensuring the cards in the player’s hand are valid instances, without any nulls. So let’s fix that.
Tightening Our Assertions
Sometimes “loose” assertions are fine, as they specify just enough of the behavior we need to get a failing test, like we did by asserting the hand had 3 things in it. Then we can use our professional judgment of the implemented code to determine if the assertions are appropriate.
In this case, we see that our “add null” implementation passes the test, but isn’t what we want[2]. Let’s adjust that by going through a “tightening phase”, ratcheting up the precision of the assertions one at a time. Note that each tightening step will still follow the Red-Green part of the TDD cycle.
Hand Should Have 3 (Non-Null) Cards
First, let’s make sure that what’s in the player’s list of cards are all actually instances of Card
.
We could examine each element to see if it’s an instance of Card
, but since the list can only hold Card
objects (yay generics), we can get away with just making sure there are no null
s in the list.
Here we’ll use AssertJ’s handy doesNotContainNull()
assertion[3]:
assertThat(game.playerCards())
.doesNotContainNull()
.hasSize(3);
Since we’ve changed what this test asserts, according to Predictive TDD, we need to state our failure prediction precisely:
This test will fail, because there is indeed a
null
in the list, and we assert there aren’t any.
java.lang.AssertionError: Expecting actual: [Card {suit=♠, rank=A}, Card {suit=♦, rank=2}, null] not to contain null elements
Success! It fails as expected. Now let’s get the test to pass by writing the least effort code[4]:
public void playerDrawCardFromDeck() {
playerHand.add(new Card("♦", "8"));
}
Creating a Card
out of thin air is allowed, and certainly requires little effort. We know it’ll be temporary, but if the test passes, then we know the test is doing its job enforcing the list to have no null
object references.
We can now predict the test will pass, and it does.
Even Tighter: Card Comes From the Deck
Let’s make our assertions even more precise and enforce the requirement that the newly added Card
actually comes from the Deck
(no cheating by pulling a card out of your sleeve!). How do we do this? The Game
code does not provide a way to directly access the Deck
object, nor a way to pass it into its constructor[5]. This is not a test problem, but a code (possibly design) problem. At this point, we have three choices:
-
Just change the implementation to take a
Card
from theDeck
instead of instantiating a newCard
. -
Add a new public query method that returns either:
a) The
Deck
instance, where we can assert that its size has been reduced by 1, or,b) Just the size of the deck, since we don’t need any other information about the deck.
-
Add a
Game
constructor that takes aDeck
instance, which allows us the option of asserting .
Option 1: Just change it
This might seem like skipping a step, but we are allowed to use our professional judgment.
We can look at the code, see that it would be correct, and make the change.
It’s one line of code, and we’re replacing something “fake” (directly instantiating a Card
) with something real (drawing the Card
from a Deck
). We don’t always have to have a failing test when doing something obvious (in our judgment) like this[6]. With this option, we would replace new Card("♦", "8")
with deck.draw()
, run the test (which would pass) and call it done.
Option 2: Add test-specific methods
Options 2a and 2b seem wrong, because we’d add public methods purely for testing. It might be appropriate as a stepping stone towards improving the overall design, but by itself it means the state is changing somewhere that we’re not able to observe. What’s worse, even with that information, we still wouldn’t know for sure that the Card
drawn from the Deck
was added to the player’s hand. The following implementation would make it seem (to an outside observer, like our test) that the right thing happened:
public void playerDrawCardFromDeck() {
deck.draw(); // reduces the number of cards in the Deck
playerHand.add(new Card("♦", "8")); // adds a different card
}
Again, we could use our professional judgment and decide we wouldn’t do something silly like that. So we could add a method to Game
:
public int deckSize() {
return this.deck.size();
}
and assert in our test that this is the expected size, like this:
assertThat(game.deckSize())
.isEqualTo(52 - 4 - 1);
// 52: original size of the `Deck`
// subtract 4: 2 cards dealt each to player & dealer
// subtract 1: the player drew a card from the deck
However, providing a test-only query method like this is a code smell[7].
Option 3: Inject Deck
into Game
Option 3 is ultimately the better way to go. We can provide Game
with a Deck
that we construct in our test, so that we know that the Card
in the player’s hand is the one that came directly from the Deck
.
We’ll see how to do this next time, leveraging some automated refactorings that IntelliJ IDEA provides. We’ll also dismantle our null
-checking scaffolding once it’s done serving its purpose.
Even though the hand is a genericized
List
that only holdsCard
instances,null
is allowed. ↩︎And possibly not even a reasonable implementation, despite it being “least effort code”. ↩︎
Interested in learning more about AssertJ’s powerful—and seemingly unending—set of assertions? Let me know on Twitter or on my Discord. ↩︎
Using a “literal” is almost always the least effort code. I consider
new Card("♦", "8")
to be a literal, because it’s not computed and no logic is used in its creation, and the Suit and Rank that make it up are themselves literals. You could call it a constant, but I often associate that with a named constant, e.g.MAX_CACHE_SIZE
. ↩︎That’s right, dependency injection, a fancy way to say “pass in an object as a parameter to the constructor”. ↩︎
As GeePaw Hill says, “Always suspect ‘always’ and ‘never’”. ↩︎
And no, adding some annotation like
@VisibleForTesting
on thedeckSize()
method does not help. We might do this as scaffolding (a temporary helping structure) to get us to somewhere better, but only if there’s no other way to go. ↩︎