Building Tic-Tac-Toe in Rust: Part 2 - Testing the Game

In the first part of this series, we built a text-based version of Tic-Tac-Toe in Rust. Now, it’s time to take our game to the next level by adding unit tests. Testing ensures that our game works as expected and remains reliable as we add more features.

In this post, we’ll:

  1. Write unit tests for the core game logic.
  2. Refactor the code for better testability.
  3. Identify and fix potential edge cases.

Step 1: Set Up Testing

Rust’s built-in test framework makes it easy to write and run tests. To get started:

  1. Add a mod tests block to your src/main.rs file.
  2. Annotate each test function with #[test].

Example:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn example_test() {
        assert_eq!(2 + 2, 4);
    }
}

Run your tests with:

cargo test

Step 2: Write Tests for Game Logic

Let’s test the most critical parts of our Tic-Tac-Toe game.

1. Testing check_winner

We’ll verify that the check_winner function correctly identifies winning combinations.

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_check_winner_row() {
        let mut game = GameState::new();
        game.board = vec![
            Some('X'), Some('X'), Some('X'),
            None, None, None,
            None, None, None,
        ];
        assert_eq!(game.check_winner(), Some('X'));
    }

    #[test]
    fn test_check_winner_diagonal() {
        let mut game = GameState::new();
        game.board = vec![
            Some('O'), None, None,
            None, Some('O'), None,
            None, None, Some('O'),
        ];
        assert_eq!(game.check_winner(), Some('O'));
    }

    #[test]
    fn test_no_winner() {
        let mut game = GameState::new();
        game.board = vec![
            Some('X'), Some('O'), Some('X'),
            None, Some('X'), None,
            None, None, Some('O'),
        ];
        assert_eq!(game.check_winner(), None);
    }
}

2. Testing Input Validation

We’ll ensure invalid inputs are handled gracefully by GameState parsing.

#[test]
fn test_invalid_input() {
    let input = "10";
    assert!(matches!(GameState::from_str(input), Err(_)));
}

Step 3: Implement FromStr for GameState

To simplify parsing and improve modularity, we’ll implement the FromStr trait for GameState. This refactor will make testing and initialization cleaner.

Updated FromStr implementation:

use std::str::FromStr;

impl FromStr for GameState {
    type Err = String;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        let mut board = vec![None; 9];
        let mut current_player = 'X';

        let trimmed_input = input.trim();
        if !trimmed_input.is_empty() {
            for (i, ch) in trimmed_input.chars().enumerate() {
                if i >= 9 {
                    return Err("Input too long".to_string());
                }
                match ch {
                    'X' | 'O' => board[i] = Some(ch),
                    '_' => (), // Empty square
                    _ => return Err("Invalid character in input".to_string()),
                }
            }

            // Infer current player based on counts
            let x_count = board.iter().filter(|&&sq| sq == Some('X')).count();
            let o_count = board.iter().filter(|&&sq| sq == Some('O')).count();

            current_player = if x_count > o_count { 'O' } else { 'X' };
        }

        Ok(GameState { board, current_player })
    }
}

Test the updated logic:

#[test]
fn test_from_str_valid() {
    let input = "XOX____OX";
    let game = GameState::from_str(input).unwrap();
    assert_eq!(game.board[0], Some('X'));
    assert_eq!(game.board[8], Some('X'));
    assert_eq!(game.current_player, 'O');
}

#[test]
fn test_from_str_invalid_character() {
    let input = "XOQ____OX";
    assert!(GameState::from_str(input).is_err());
}

#[test]
fn test_from_str_empty() {
    let input = "_________";
    let game = GameState::from_str(input).unwrap();
    assert!(game.board.iter().all(|&sq| sq.is_none()));
    assert_eq!(game.current_player, 'X');
}

Step 4: Run the Tests

Run your tests with:

cargo test

You should see output like:

test tests::test_check_winner_row ... ok
test tests::test_check_winner_diagonal ... ok
test tests::test_no_winner ... ok
test tests::test_from_str_valid ... ok
test tests::test_from_str_invalid_character ... ok
test tests::test_from_str_empty ... ok

Wrapping Up

With the FromStr implementation for GameState, our Tic-Tac-Toe game is now easier to initialize and test. We’ve:

  1. Implemented FromStr to handle board parsing and initialization.
  2. Tested critical game logic like checking winners and parsing inputs.
  3. Ensured our game gracefully handles invalid scenarios.

In the next post, we’ll explore adding features like saving and loading game states. Until then, try adding more tests or experimenting with additional features!

As always, you can find the code for this post on GitHub: Tic-Tac-Toe Repository.