• בלוג
  • יום 6 - כתיבת בדיקות יחידה ב Rust

יום 6 - כתיבת בדיקות יחידה ב Rust

01/01/2023

ראסט מציע תמיכה מובנית בכתיבת בדיקות יחידה למודולים. בשיעור זה נתחיל לבנות משחק Snake ונראה איך לבדוק את קוד המשחק שלנו לפני שמתחילים לצייר את ה GUI.

1. מה אנחנו בונים

המטרה היא לבנות משחק סנייק, אבל לא נספיק לבנות אותו ביום אחד, ולכן אני מחלק את העבודה לשני פוסטים - היום נכתוב את הלוגיקה של המשחק ונשתמש בבדיקות יחידה כדי לראות שאנחנו בכיוון, ומחר נחבר לו ממשק משתמש גרפי כדי שאפשר יהיה גם לשחק.

2. קוד המשחק

בשביל לבנות משחק סנייק אני צריך שני דברים - אחד הוא קובץ הלוגיקה שאחראי על המיקום של הנחש, התנועה והתאונות שאולי קורות לנחש. השני הוא הממשק הגרפי. בניית הלוגיקה מראש ובדיקה שלה תעזור לנו כשנגיע לכתוב את קוד ה UI, כי אז נוכל להתמקד בממשק ולהיות רגועים לגבי מבנה המשחק.

לכן המטרה שלי היום היא לבנות את הפונקציות של הלוגיקה של הנחש, ולהשתמש בבדיקות יחידה כדי לוודא שכל הפונקציות שלי עובדות טוב. ארצה לבנות:

  1. סטראקט בשם Game שמייצג את מצב המשחק - איפה הנחש, איפה התפוח ומה גודל המסך.

  2. פונקציה בשם step שמקבלת את הכיוון של הנחש ומזיזה את הנחש קדימה. הפונקציה אחראית לבדוק אם הנחש אכל את התפוח או הנתגש בקיר והיא מחזירה משתנה בוליאני, ערך אמת אומר שאפשר להמשיך לשחק וערך שקר אומר שהמשחק הסתיים.

  3. פונקציות בדיקה לשתי הפונקציות שתיארתי, כדי שלא נהיה מופתעים כשנחבר ממשק גרפי.

נתחיל עם הדברים שאנחנו כבר מכירים. בשביל לכתוב קוד בקובץ נפרד אני פשוט פותח את הקובץ בעורך הטקסט. כל דבר שארצה להשתמש בו צריך לקבל את התחילית pub, לכן מבנה הנתונים של המשחק יהיה:

pub struct Game {
    pub size: Vec2,
    pub snake: LinkedList<XY<usize>>,
    pub apple: XY<usize>,
    pub rng: ThreadRng,
}

ובשביל לדעת מה הכיוון בו מתקדמים אני יכול להגדיר enum:

pub enum Direction {
  Up,
  Down,
  Left,
  Right,
}

בשביל המספרים האקראיים (איפה יהיה התפוח) התקנתי ספריה חיצונית בשם rand. אני טוען אותה וגם מייבא את LinkedList למרחב השמות שלי עם הפקודות:

use std::collections::LinkedList;
use rand::{thread_rng, Rng, rngs::ThreadRng};

וממשיכים לקוד של המשחק. אנחנו יודעים שבדרך כלל אחרי שמגדירים Struct אפשר פשוט לתת ערכים לכל השדות שלו כדי לקבל משתנה מאותו הסוג, כלומר במקרה של Game אפשר היה לכתוב:

let game = Game {
    size: Vec2 { x: 20, y: 20 },
    snake: LinkedList::from([ XY::new(10, 10)]),
    apple: XY::new(5, 5),
    rng: thread_rng(),
};

אבל בשביל שיהיה נוח לקוד שמשתמש ב Struct אני יוצר פונקציה בשם new שמחזירה משחק בדיוק עם הערכים האלה. בצורה כזאת קוד חיצוני שישתמש במשחק יוכל לכתוב רק:

let mut game = Game::new();

הפונקציה אגב נראית כך:

pub fn new() -> Self {
    let game = Game {
        size: Vec2 { x: 20, y: 20 },
        snake: LinkedList::from([ XY::new(10, 10)]),
        apple: XY::new(5, 5),
        rng: thread_rng(),
    };

    return game;
}

והיא מוגדרת בתוך בלוק impl של המשחק:

impl Game {
    // all "Game" functions go here
    // pub fn new() -> Self { ... }
}

הפונקציה השניה והיותר מעניינת שמוגדרת על Game היא step, והיא זאת שמזיזה את המשחק צעד אחד קדימה. ככל שנפעיל אותה לעתים יותר קרובות כך המשחק יהיה יותר קשה. הנה הקוד:

    pub fn step(&mut self, dir: Direction) -> bool {
      let head = self.snake.front().unwrap();

      let next_head = match dir {
        Direction::Up => XY::new(head.x, head.y - 1),
        Direction::Down => XY::new(head.x, head.y + 1),
        Direction::Left => XY::new(head.x - 1, head.y),
        Direction::Right => XY::new(head.x + 1, head.y),
      };

      if (next_head.x >= self.size.x) || (next_head.x == 0) || (next_head.y >= self.size.y) || (next_head.y == 0) {
        return false;
      }

      self.snake.push_front(next_head);

      if (next_head.x != self.apple.x) || (next_head.y != self.apple.y) {
        self.snake.pop_back();
      } else {
        // ate an apple
        self.apple.x = self.rng.gen_range(0..self.size.x);
        self.apple.y = self.rng.gen_range(0..self.size.y);
      }

      return true;
    }

הרבה מה לקרוא כאן, אז בואו נדבר על הדברים המרכזיים:

  1. אני מגדיר את הנחש בתור רשימה מקושרת, כדי שיהיה קל למחוק את החוליה האחרונה ולהוסיף חוליה לראש (כלומר לזוז). הפונקציה מתחילה בלקיחת הערך הראשון מאותה רשימה, כלומר ראש הנחש. אני יודע שהוא חייב להיות שם ולכן לא מודאג מהשימוש ב unwrap כדי לקבל את הערך.

  2. בעזרת match אני בונה את next_head להיות המיקום הבא של הנחש.

  3. אני בודק אם הנחש יצא מהלוח, ואם כן מחזיר false.

  4. מוסיף את המיקום החדש לראש הנחש.

  5. ומסיימים בתפוח - אם הנחש אכל עכשיו תפוח מגרילים מקום חדש לתפוח הבא, אם הנחש לא אכל את התפוח מורידים את החוליה האחרונה מהזנב.

3. איך כותבים בדיקות

בשביל לדעת שקוד הנחש עובד לפני שיש לנו ממשק גרפי, אפשר להפעיל אותו מתוך פונקציות בדיקה. ב Rust אני מגדיר מודול בדיקה ממש באותו קובץ עם הקוד הרגיל עם הפקודות:

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

כל פונקציית בדיקה בתוך המודול צריכה לקבל את המאפיין test, לדוגמה:

#[test]
fn test_can_move() {
}

ובתוך פונקציית בדיקה אני יכול להשתמש בפונקציות עם התחילית assert כדי לבדוק שדברים מכילים את הערך שאני צריך. לדוגמה:

#[test]
fn test_can_move() {
  let mut game = Game::new();
  game.step(Direction::Up);
  game.step(Direction::Up);
  game.step(Direction::Up);

  assert_eq!(game.snake.front().unwrap().x, 10);
  assert_eq!(game.snake.front().unwrap().y, 7);
  assert_eq!(game.snake.len(), 1);
}

פונקציית הבדיקה יוצרת משחק חדש, מזיזה את הנחש שלושה צעדים למעלה ואז בודקת את המיקום של הנחש ואת הגודל שלו. מאחר ואני יודע איפה הנחש הראשון מתחיל ואיפה התפוח מתחיל אני בטוח שכל פעם שאפעיל את הבדיקה היא תעבור.

במאגר בגיטהאב תוכלו למצוא עוד כמה בדיקות שכתבתי כדי לבדוק שהנחש מצליח לאכול את התפוח ושהמשחק מסתיים כשהנחש מתנגש בקיר.

בשביל להריץ את הבדיקות משורת הפקודה אני כותב:

cargo test

הפלט כולל אזהרות על משתנים שלא השתמשתי בהם ואת הטקסט:

running 3 tests
test game::tests::test_can_move ... ok
test game::tests::test_can_eat_apple ... ok
test game::tests::test_can_hit_a_wall ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

יש המון אפשרויות להפעלת טסטים, למשל בשביל להפעיל רק טסטים מקובץ מסוים או שהשם שלהם מתאים למחרוזת מסוימת. תוכלו להפעיל משורת הפקודה cargo test --help כדי לקבל רשימה של כל האופציות.

4. תרגילים להרחבה

מחר נכתוב GUI למשחק סנייק, אבל כבר בינתיים יש הרבה מה לעשות עם הנחש שכבר בנינו. הנה כמה תרגילים שיעזרו לכם להתיידד עם הקוד ועם הרעיון של בדיקות בראסט:

  1. הוסיפו בדיקות כדי לוודא שהמשחק יסתיים אם הנחש יתנגש בקירות האחרים (כרגע בריפו בודקים רק את הקיר העליון).

  2. עדכנו את הקוד כך שהמשחק יסתיים גם אם הנחש מתנגש בעצמו. הוסיפו בדיקות כדי לוודא שהעדכון שלכם עובד.

  3. הוסיפו תמיכה ב"תפוח זהב". תפוח זהב יכול להופיע במקום תפוח רגיל, ואחרי שאוכלים אותו הנחש גדל ב-4 חוליות. אל תשכחו להוסיף בדיקות מתאימות לתפוח הזהב שלכם.

מוזמנים להדביק פה את הפיתרונות שלכם להרחבות ומחר נמשיך לכתוב GUI למשחק הנחש.