"Life is all about sharing. If we are good at something, pass it on." - Mary Berry

Integration testing TUI applications in Rust

2024-01-21

Categories: Programming

In building games with any language, there will be a loop to handle the key events. In case of crossterm, it’s event::read:

 1if poll(Duration::from_millis(10))? {
 2    let event = read()?;
 3    match event {
 4        Event::Key(KeyEvent {
 5            code,
 6            state: _,
 7            kind,
 8            modifiers: _,
 9        }) => {
10            if kind == KeyEventKind::Press {
11                let mut tetromino = self.current_tetromino.clone();
12                match code {
13                    KeyCode::Char('h') | KeyCode::Left => {
14                        tetromino.move_left(self, stdout)?;
15                        self.current_tetromino = tetromino;
16                    }

To make this code testable, we can add a Trait:

1pub trait Terminal {
2    fn poll_event(&self, duration: Duration) -> Result<bool>;
3    fn read_event(&self) -> Result<Event>;
4}

and implement it for the real terminal:

 1pub struct RealTerminal;
 2
 3impl Terminal for RealTerminal {
 4    fn poll_event(&self, duration: Duration) -> Result<bool> {
 5        Ok(poll(duration)?)
 6    }
 7
 8    fn read_event(&self) -> Result<Event> {
 9        Ok(read()?)
10    }
11}

Now the Game struct can be modified to:

1pub struct Game {
2    terminal: Box<dyn Terminal>,
3}
 1if self.terminal.poll_event(Duration::from_millis(10))? {
 2    if let Ok(event) = self.terminal.read_event() {
 3        match event {
 4            Event::Key(KeyEvent {
 5                code,
 6                state: _,
 7                kind,
 8                modifiers: _,
 9            }) => {
10                if kind == KeyEventKind::Press {
11                    let mut tetromino = self.current_tetromino.clone();
12                    match code {
13                        KeyCode::Char('h') | KeyCode::Left => {
14                            tetromino.move_left(self, stdout)?;
15                            self.current_tetromino = tetromino;
16                        }

When writing integration test, we can create MockTerminal struct with a field to mock the key code:

 1struct MockTerminal {
 2    mock_key_code: Option<Receiver<KeyCode>>,
 3}
 4
 5impl Terminal for MockTerminal {
 6    fn poll_event(&self, duration: Duration) -> Result<bool> {
 7        thread::sleep(duration);
 8        Ok(true)
 9    }
10
11    fn read_event(&self) -> Result<Event> {
12        if let Some(mock_key_code) = &self.mock_key_code {
13            if let Ok(code) = mock_key_code.recv() {
14                println!("Received: {:?}", code);
15                return Ok(Event::Key(KeyEvent {
16                    code,
17                    modifiers: KeyModifiers::empty(),
18                    kind: KeyEventKind::Press,
19                    state: KeyEventState::empty(),
20                }));
21            }
22        }
23
24        Ok(Event::Key(KeyEvent {
25            code: KeyCode::Null,
26            modifiers: KeyModifiers::empty(),
27            kind: KeyEventKind::em
28            state: KeyEventState::empty(),
29        }))
30    }

Here, we use mpsc::channel to drive the main thread. On the receiver side, we wait for a value and return the corresponding KeyEvent.

 1#[test]
 2fn clear_lines() -> Result<()> {
 3    let (tx, rx): (Sender<KeyCode>, Receiver<KeyCode>) = channel();
 4    let mut game = Game::new(
 5        Box::new(MockTerminal::new(Some(rx))),
 6        tetromino_spawner,
 7        sqlite_highscore_repository,
 8        40,
 9        20,
10        0,
11        0,
12        None,
13        None,
14        Some(play_grid_tx),
15    )?;
16
17    let receiver = thread::spawn(move || {
18        game.start().unwrap();
19    });
20
21    let previous_col = game.current_tetromino.position.col;
22    tx.send(KeyCode::Char('h')).unwrap();
23    assert_eq!(game.current_tetromino.position.col, previous_col - 1);

Upon running cargo test, I encountered an error:

 1error[E0382]: borrow of moved value: `game`
 2   --> tests/integration_test.rs:146:24
 3    |
 4129 |     let mut game = Game::new(
 5    |         -------- move occurs because `game` has type `Game`, which does not implement the `Copy` trait
 6...
 7142 |     let receiver = thread::spawn(move || {
 8    |                                  ------- value moved into closure here
 9143 |         game.start().unwrap();
10    |         ---- variable moved due to use in closure
11...
12146 |     let previous_col = game.current_tetromino.position.col;
13    |                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ value borrowed here after move

As indicated by the compiler, since Game is started in a separate thread, its value cannot be borrowed after move. To address this, I tried using Arc<Mutex<T>> to share Game state between multiple threads:

 1let game = Arc::new(Mutex::new(Game::new(
 2    Box::new(MockTerminal::new(Some(rx))),
 3    tetromino_spawner,
 4    sqlite_highscore_repository,
 5    40,
 6    20,
 7    0,
 8    0,
 9    None,
10    None,
11    None,
12)?));
13
14let receiver = thread::spawn({
15    let game = Arc::clone(&game);
16    move || {
17        let mut game_lock = game.lock().unwrap();
18        game_lock.start().unwrap();
19    }
20});
21
22let game_lock = game.lock().unwrap();
23let previous_col = game_lock.current_tetromino.position.col;
24tx.send(KeyCode::Char('h')).unwrap();
25assert_eq!(game_lock.current_tetromino.position.col, previous_col - 1);
26
27receiver.join().unwrap();
28
29Ok(())

Upon re-running cargo test -- --nocapture, I got another error:

 1Running tests/integration_test.rs (target/debug/deps/integration_test-81597c4542f1a01f)
 2
 3running 1 test
 4thread 'clear_lines' panicked at tests/integration_test.rs:155:5:
 5assertion `left == right` failed
 6  left: 3
 7 right: 2
 8note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
 9thread '<unnamed>' panicked at tests/integration_test.rs:147:45:
10called `Result::unwrap()` on an `Err` value: PoisonError { .. }
11test clear_lines ... FAILED
12
13failures:
14
15failures:
16    clear_lines
17
18test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

However, I realized that the assertion is performed before the game thread is completed, resulting in the tetromino position column not being updated. Furthermore, if I send q (quit), then y (confirm) to terminate the game thread, then there is… nothing to assert. This situation has left me in a dilemma.

After seeking advice on the Rust forum and receiving guidance from parasyte:

What I meant with the “return channel” was literally passing the state back through the channel for assertions. It’s very easy to do if you don’t mind cloning the state for your tests.

I decided to pass the play grid state back through the channel for assertions after the tetromino reaches the bottom:

 1pub struct Game {
 2    terminal: Box<dyn Terminal + Send>,
 3    ...
 4    // This is only used for integration testing purposes
 5    state_sender: Option<Sender<Vec<Vec<Cell>>>>,
 6}
 7
 8impl Game {
 9    fn lock_and_move_to_next(
10        &mut self,
11        tetromino: &Tetromino,
12        stdout: &mut io::Stdout,
13    ) -> Result<()> {
14        self.lock_tetromino(tetromino)?;
15
16        // When performing integration testing, Game instance is started in a spawned thread
17        // This sends the play grid state to the main thread, so it can be asserted.
18        if let Some(state_sender) = &self.state_sender {
19            state_sender.send(self.play_grid.clone())?;
20        }
21
22        self.move_to_next()?;
23
24        if self.is_game_over() {
25            self.handle_game_over(stdout)?;
26        }
27
28        Ok(())
29    }
 1#[test]
 2fn clear_lines() -> Result<()> {
 3    let tetromino_spawner = Box::new(ITetromino);
 4    let conn = Connection::open_in_memory()?;
 5    let sqlite_highscore_repository = Box::new(HighScoreRepo { conn });
 6
 7    let (tx, rx): (Sender<KeyCode>, Receiver<KeyCode>) = channel();
 8    let (play_grid_tx, play_grid_rx): (Sender<Vec<Vec<Cell>>>, Receiver<Vec<Vec<Cell>>>) =
 9        channel();
10    let mut game = Game::new(
11        Box::new(MockTerminal::new(Some(rx))),
12        tetromino_spawner,
13        sqlite_highscore_repository,
14        40,
15        20,
16        0,
17        0,
18        None,
19        None,
20        Some(play_grid_tx),
21    )?;
22
23    let receiver = thread::spawn(move || {
24        game.start().unwrap();
25    });
26
27    // Clear a line by placing 4 I tetrominoes like this ____||____
28    // Move the first I tetromino to the left border
29    tx.send(KeyCode::Char('h')).unwrap();
30    tx.send(KeyCode::Char('h')).unwrap();
31    tx.send(KeyCode::Char('h')).unwrap();
32    tx.send(KeyCode::Char('j')).unwrap();
33    if let Ok(play_grid) = play_grid_rx.recv() {
34        for col in 0..4 {
35            assert_eq!(play_grid[19][col], I_CELL);
36        }
37    }
38
39    // // Move the 2nd I tetromino to the right border
40    tx.send(KeyCode::Char('l')).unwrap();
41    tx.send(KeyCode::Char('l')).unwrap();
42    tx.send(KeyCode::Char('l')).unwrap();
43    tx.send(KeyCode::Char('j')).unwrap();
44    if let Ok(play_grid) = play_grid_rx.recv() {
45        for col in 6..10 {
46            assert_eq!(play_grid[19][col], I_CELL);
47        }
48    }
49
50    // Rotate the 3rd I tetromino, move left one column, then hard drop
51    tx.send(KeyCode::Char(' ')).unwrap();
52    tx.send(KeyCode::Char('h')).unwrap();
53    tx.send(KeyCode::Char('j')).unwrap();
54    if let Ok(play_grid) = play_grid_rx.recv() {
55        for row in 16..20 {
56            assert_eq!(play_grid[row][4], I_CELL);
57        }
58    }
59
60    // Rotate the 4th I tetromino, then hard drop to fill a line
61    tx.send(KeyCode::Char(' ')).unwrap();
62    tx.send(KeyCode::Char('j')).unwrap();
63    if let Ok(play_grid) = play_grid_rx.recv() {
64        for col in 0..4 {
65            assert_eq!(play_grid[19][col], EMPTY_CELL);
66        }
67        for col in 6..10 {
68            assert_eq!(play_grid[19][col], EMPTY_CELL);
69        }
70        for row in 18..20 {
71            for col in 4..6 {
72                assert_eq!(play_grid[row][col], I_CELL);
73            }
74        }
75    }
76
77    tx.send(KeyCode::Char('q')).unwrap();
78    tx.send(KeyCode::Char('y')).unwrap();
79
80    receiver.join().unwrap();
81
82    Ok(())
83}

and cargo test -- --nocapture worked like a charm:

 1     Running tests/integration_test.rs (target/debug/deps/integration_test-81597c4542f1a01f)
 2
 3running 1 test
 4Received: Char('h')
 5Received: Char('h')
 6Received: Char('h')
 7Received: Char('j')
 8Received: Char('l')
 9Received: Char('l')
10Received: Char('l')
11Received: Char('j')
12Received: Char(' ')
13Received: Char('h')
14Received: Char('j')
15Received: Char(' ')
16Received: Char('j')
17Received: Char('q')
18Received: Char('y')

Tags: integration-testing rust

Edit on GitHub

Related Posts: