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
Related Posts:
- libp2p performance benchmarking
- Learning Rust by building Tetris: my favorite childhood game
- SICP Exercise 2.77: expected a procedure that can be applied to arguments, given #f
- A terminal UI for Taskwarrior
- A simple terminal UI for ChatGPT