Test-driving Tetris from the outside in (2)
Sep 12, 2019
tdd
tetris
rust
|
Prev
|
Next
Before we begin:
- I talk about I-blocks and the like in there, which refers to this classification
- The code referenced is on github
I want to go into a bit more detail on how everything is put together by way of a more complex example. So, in Tetris, a line goes away as soon at it is full, right?
What follows is the first test I wrote to drive this behavior. It is the simplest one in vanishing_lines_test.rs:
#[test]
fn single_line_vanishes() {
let mut game = TestableGame::init()
.with_brick_type_encoding()
.with_brick_sequence(vec![i_block(), z_block()])
.with_field(vec![
"..........",
"..........",
"o....zzool",
])
.build();
game.verify_frame_after(0, vec![
".iiii.....",
"..........",
"o....zzool",
]);
game.tick(100);
game.verify_frame_after(200, vec![
"..........",
"..........",
"oiiiizzool",
]);
game.verify_frame_after(300, vec![
".zz.......",
"..zz......",
"..........",
]);
}
This test does roughly this:
- set up the game and tailor it to this specific test case
- verify field after the first tick (I-block appears on top)
- tick for 100ms
- verify field after 200ms (I-block has dropped down completely)
- verify field after 300ms (last line is gone, next brick has appeared on top)
It depends on two key ingredients:
- The ability to create the system with specific parameters tailored to our testing needs
- The ability to inject a dependency under our control that represents the output
The first ingredient is provided by a struct called GameBuilder which is created by the call to Game::init - the details are not really important here, but suffice it to say that the builder pattern comes in very handy when the game needs to be tweaked for different testing scenarios.
In this case, we are saying, well, the game field is really small, like 3 rows, and it already has some stuff in it. Also, please spawn blocks in a specific sequence (I-block then Z-block).
Secondly, when the game renders itself in the test, it uses a struct called ToStringRenderer, which implements a trait called TRenderer:
pub trait TRenderer {
fn clear(&mut self);
fn draw_bricklet_at(&mut self, x: u8, y: u8);
}
This is a very domain specific trait. It allows us to care only about the abstraction of a brick being a collection of little squares. How these squares appear on a screen is of no concern here. Heck, we can even choose what the screen is. And we say it is a bunch of strings. Hooray!
With this in hand, we can just say that, hey, a bricklet is now just a character based on the block type (i-block, t-block, o-block, etc) in a string, and it is at some position in an array of strings, and we can take this array and write an assertion against it, right there in the code. And that assertion will have a very natural look, being a visual representation of our assumption.
That verify_frame_after function is just a convenience function, it looks like this:
pub fn verify_frame_after(&mut self, now: u64, expected_frame: Vec<&str>) {
self.game.tick(now);
self.render();
self.renderer.assert_frame(expected_frame);
}
pub fn render(&mut self) {
self.game.render(&mut self.renderer);
}
So as you can see here, the ToStringRenderer has its own assert function, which is fine because it is only used in tests anyway.
One last neat thing: assert_frame will internally convert both the actual and the expected frame into a string divived by newline characters. This enables an IDE like Intellij IDEA to provide us with an actual diff, should the test fail. Have a look at this:
So if we somehow messed up our “vanishing lines” algorithm, this is what a test failure would look like.
And I think that is pretty neat.