Unit test is all about confidence

เวลาที่ผมเขียนโปรแกรมอยู่ ถ้าจะอยากลองทดสอบว่ามันทำงานได้เหมือนที่ผมต้องการไหม ผมมักจะรันโปรแกรมแล้วก็ลองใช้ดูเลยว่ามันทำงานถูกต้องตามที่คิดไหม ซึ่งจริงๆ แล้วมันก็เป็นวิธีที่ได้ผลดีนะ สำหรับโปรแกรมง่ายๆ ที่ทำงานอย่างใดอย่างหนึ่ง ก็แค่หยุดเขียนโค้ด ออกไป compile รอแล้วก็ลองเล่นดูถ้าไม่ถูกก็กลับมาแก้โค้ดแล้วก็ลองใหม่ แต่ว่าถ้าโปรแกรมมันเริ่มซับซ้อนล่ะ ผมต้องเข้าไปเล่นลึกๆ กว่าโปรแกรมจะรัน กว่าจะ compile เสร็จ ถ้ายังแก้ไม่ถูกก็จะเริ่มเป็นลูปที่ใช้เวลามากขึ้นเรื่อยๆ แล้วยิ่งถ้ามันเป็นบัคที่เกิดขึ้นได้ยากๆ ผมจะต้องคิดว่ามัน reproduce ยังไง ต้องแก้โค้ดแล้วก็ต้อง reproduce มันซึ่งบางทีมันก็เฉพาะเจาะจงมากๆ ทำให้ลำบากในการทดสอบด้วย

ที่ผมกล่าวมาก่อนหน้านี้คือสิ่งที่เรียกว่า manual test ครับ (หรือ การทดสอบด้วยมือ) ซึ่งเป็นวิธีทดสอบแบบทั่วไป ได้ผลดี แต่ก็มีข้อเสียเพราะว่าเราใช้คนทดสอบ และใช้เวลา โดยความถูกต้องก็จะขึ้นอยู่กับคนทดสอบซะมากกว่า และได้ feedback loop ที่ช้า เพราะว่าต้องเล่นจริงจากผลจริงๆ ซึ่งในมุมหนึ่งมันสามารถสร้างความมั่นใจให้กับการทดสอบได้มาก แต่ว่าผมเนี่ยเวลาทำอะไรคนเดียว จะมานั่งทดสอบเองไปซะทุกอย่างมันก็น่าหน่ายเหมือนกัน เราต้องหาอะไรมาช่วยลดภาระตรงนี้ลง

Automated test

การทดสอบแบบอัตโนมัติ นี่แหละครับคือทางออก ไม่ต้องเสียเวลาเล่นเอง ไม่ต้องเตรียมข้อมูลให้ยุ่งยากหลายๆ ครั้ง ทำครั้งเดียว เวลาทดสอบใหม่ ก็แค่รันเทสใหม่ ช่วยเพิ่มความมั่นใจให้การทำงานได้เยอะ


Unit testing

Unit test ไม่ได้ทำให้โปรแกรมเราถูกต้อง แต่มันช่วยเรามั่นใจว่ามันทำงานตามที่เราคิด

ได้เวลาเข้าเรื่องหลังจากที่เกริ่นมานาน วันนี้ผมก็อยากจะมาพูดถึงความสุดยอดของ unit test ครับ ช่วงนี้ผมเองกำลังเขียนเกมโดยใช้ Bevy อยู่ Bevy เนี่ยเป็น Game engine ที่เขียนด้วยภาษา Rust ซึ่งตัวภาษาเองรองรับการเขียน automated test อยู่ด้วย แต่ว่าผมเองนั้นไม่ได้ใช้ภาษานี้ในการทำงานหรือใดๆ เท่าไร ส่วนใหญ่จะลองเขียนอะไรเล่นๆ ก็เลยไม่ได้ใช้ความสามารถส่วนนี้มาก แล้วเกมที่ผมกำลังเขียนมันอยู่ในช่วงเริ่มต้นที่ระบบทุกอย่างมันตรงไปตรงมา ไม่ได้ซับซ้อนมี logic อะไรให้ต้องคิดมาก

ลองดูโค้ดต่อไปนี้ดูครับ

pub fn block_fall_intent(
    mut commands: Commands,
    mut blocks: Query<(Entity, &Block, &mut BlockSlot), Without<BlockOutOfBound>>,
    grid: Res<GridTable>,
) {
    for (entity, block, block_slot) in blocks.iter_mut() {
        let current_coord = grid.index_to_coord(block_slot.0 as usize);
        let next_slot = grid
            .index_from_coord(&current_coord.with_y(current_coord.y + (block.speed() + 1) as i32));

        commands
            .entity(entity)
            .insert(BlockMoveIntend(next_slot as u64));
    }
}

pub fn block_position_resolve(
    mut commands: Commands,
    mut blocks: Query<(Entity, &Block, &mut BlockMoveIntend), Without<BlockOutOfBound>>,
    _grid: Res<GridTable>,
) {
    for (entity, _, move_intent) in blocks
        .iter_mut()
        .sort_by_key::<&BlockMoveIntend, _>(|a| a.0)
        .rev()
    {
        let resolved = BlockMoveResolve(move_intent.0);
        commands.entity(entity).insert(resolved);
    }
}

pub fn block_position_commit(
    mut commands: Commands,
    mut blocks: Query<
        (Entity, &mut BlockSlot, &BlockMoveResolve),
        (With<Block>, Without<BlockOutOfBound>),
    >,
    mut grid: ResMut<GridTable>,
) {
    for (entity, mut block_slot, block_resolved) in blocks.iter_mut() {
        grid.claim_release(block_resolved.0 as usize, block_slot.0 as usize);

        block_slot.0 = block_resolved.0;

        commands
            .entity(entity)
            .remove::<BlockMoveIntend>()
            .remove::<BlockMoveResolve>();
    }
}

สำหรับคนที่ไม่ได้เขียนโปรแกรมอาจจะดูไม่ออกว่ามันคืออะไร แต่ว่ามันคือระบบของเกม 3 ระบบที่ทำหน้าที่ร่วมกัน

  1. กำหนดตำแหน่งเป้าหมายที่ block จะร่วงลงไปอยู่
  2. ยืนยัน และจัดการตำแหน่งที่ block ต้องการจะไป
  3. ใช้งาน

ทุกอย่างตรงไปตรงมาไม่มีการปรับเปลี่ยนอะไร block อยากไปลงตรงไหนก็ลงไปตรงนั้น ด้วยความขี้เกียจก็ไม่ต้องทดสอบอะไรมากไปลองเล่นกัน (มั่นใจสุดๆ อยู่แล้ว)

จนไม่นานมานี้ เริ่มมีการ implement เพิ่มเติมตรงจุดนี้ โดยผมต้องการให้ block ร่วงโดยไม่ลอยข้ามกัน ลองดูโค้ดใหม่อีกที

pub fn block_position_resolve(
    mut commands: Commands,
    mut blocks: Query<
        (Entity, &BlockSlot, &BlockMoveIntend),
        (
            With<Block>,
            Or<(Without<BlockOutOfBound>, Without<BlockDestroy>)>,
        ),
    >,
    grid: Res<GridTable>,
) {
    let mut grid = grid.0.clone();

    for (entity, block_slot, move_intent) in blocks
        .iter_mut()
        .sort_by_key::<&ฺBlockMoveIntend, _>(|a| a.0)
        .rev()
    {
        let block_slot = block_slot.0;
        let target_slot = move_intent.0;
        let mut block_coord = grid.index_to_coord(block_slot as usize);
        let target_coord = grid.index_to_coord(target_slot as usize);
        let mut step = target_coord.y - block_coord.y;
        let mut resolved = block_slot;

        while step != 0 {
            block_coord.y += 1;

            let next_slot = grid.index_from_coord(&block_coord);

            if grid.claimable(next_slot) {
                resolved = next_slot as u64;
            } else {
                break;
            }

            step -= 1;
        }

        grid.claim_release(resolved as usize, block_slot as usize);

        commands.entity(entity).insert(BlockMoveResolve(resolved));
    }
}

เริ่มเยอะแล้ว แล้วผมทดสอบยังไงดี งั้นก็ลองเล่นเลย (ไปแบบไม่ค่อยมั่นใจ แต่ก็คิดว่าไม่พลาด) ซึ่งผลคือ ไม่เป็นไปตามคาดครับ มันทำงานผิด block ยังวิ่งข้ามกันอยู่ดี คือเอาจริงๆ ผมอยู่ใน loop นี้นานกว่าที่ผมเอามาเขียนในบทความนะ ในที่สุด ความขี้เกียจของผมก็เริ่มมีมากขึ้น ผมขี้เกียจ manual เองล่ะ เพราะว่าตอน block มันร่วงมาเนี่ยผมใช้การสุ่ม ยังไม่ได้เตรียมตัวทำให้มัน reproduce ของง่ายๆ ซึ่งก็ต้องใช้ตามองแล้วก็นั่งรอ งั้นมาใช้ของที่ rust เตรียมมาให้ดีกว่า

เขียน Test

ผมจะไม่ได้ลงรายละเอียดมากนะ กลัวว่ามันจะยืดยาวและ technical เกินไป แต่ว่าผมก็เริ่มการเขียนเทสเพื่อทดสอบระบบส่วนนี้ว่ามันทำงานถูกต้องไหม และเนื่องจากผมไม่ได้เขียน test ไว้แต่แรก ผมเลยเริ่มเขียนจาก test ง่ายๆ ก่อน เพื่อทดสอบว่า มันสามารถทำงานแบบพื้นๆ ได้ถูกอย่างที่ผมคิดไหม

#[test]
fn block_drop_down_by_one() {
    let mut app = setup();

    app.add_systems(
        Update,
        (
            block_fall_intent,
            block_position_resolve,
            block_position_commit,
        )
            .chain(),
    );

    let entity1 = app.world_mut().spawn((Block(0), BlockSlot(0))).id();

    let entity2 = app.world_mut().spawn((Block(0), BlockSlot(2))).id();

    app.update();

    let grid = app.world().get_resource::<GridTable>().unwrap();

    let index_e1 = grid.index_from_coord(&IVec2::new(0, 1)) as u64;
    let index_e2 = grid.index_from_coord(&IVec2::new(2, 1)) as u64;

    assert_eq!(
        app.world().get::<BlockSlot>(entity1).unwrap().deref(),
        &index_e1
    );

    assert_eq!(
        app.world().get::<BlockSlot>(entity2).unwrap().deref(),
        &index_e2
    );

    assert!(grid.claimable(0));
    assert!(grid.claimable(2));
    assert!(!grid.claimable(index_e1 as usize));
    assert!(!grid.claimable(index_e2 as usize));
}

test นี้ง่ายๆ สร้าง block มา 2 ตัว เริ่มต้นกันคนละตำแหน่งกัน แต่ละ block ต้องลงมาได้ตามตำแหน่งของตัวเอง ซึ่งก็ผ่านฉลุย

#[test]
fn block_drop_with_speed() {
    let mut app = setup();

    app.add_systems(
        Update,
        (
            block_fall_intent,
            block_position_resolve,
            block_position_commit,
        )
            .chain(),
    );

    let entity1 = app.world_mut().spawn((Block(0), BlockSlot(0))).id();

    let entity2 = app.world_mut().spawn((Block(1 << 4), BlockSlot(2))).id();

    app.update();

    let grid = app.world().resource_ref::<GridTable>();

    let index_e1 = grid.index_from_coord(&IVec2::new(0, 1)) as u64;
    let index_e2 = grid.index_from_coord(&IVec2::new(2, 2)) as u64;

    assert_eq!(
        app.world().get::<BlockSlot>(entity1).unwrap().deref(),
        &index_e1
    );

    assert_eq!(
        app.world().get::<BlockSlot>(entity2).unwrap().deref(),
        &index_e2
    );

    assert!(grid.claimable(0));
    assert!(!grid.claimable(index_e1 as usize));
    assert!(!grid.claimable(index_e2 as usize));
}

อ่า เอาจริงๆ พอมาเขียนบทความนี้แล้ว ผมชักจะเริ่มคิดว่า จริงๆ test นี้ค่อนข้างจะซ้ำกับอันก่อนหน้า แต่ว่าไม่เป็นไร หลักๆ คือผมเพิ่มความเร็วของ block ให้ไม่เท่ากันแล้วก็ควรร่วงลงตำแหน่งที่ถูก ซึ่งก็ผ่าน

#[test]
fn block_drop_behind() {
    let mut app = setup();

    app.add_systems(
        Update,
        (
            block_fall_intent,
            block_position_resolve,
            block_position_commit,
        )
            .chain(),
    );

    let entity1 = app.world_mut().spawn((Block(0), BlockSlot(4))).id();

    let entity2 = app.world_mut().spawn((Block(0), BlockSlot(0))).id();

    app.update();

    let grid = app.world().resource_ref::<GridTable>();

    let index_e1 = grid.index_from_coord(&IVec2::new(0, 2)) as u64;
    let index_e2 = grid.index_from_coord(&IVec2::new(0, 1)) as u64;

    assert_eq!(
        app.world().get::<BlockSlot>(entity1).unwrap().deref(),
        &index_e1
    );

    assert_eq!(
        app.world().get::<BlockSlot>(entity2).unwrap().deref(),
        &index_e2
    );

    assert!(grid.claimable(0));
    assert!(!grid.claimable(index_e1 as usize));
    assert!(!grid.claimable(index_e2 as usize));
}

test นี้ ลองขยับเข้าใกล้สิ่งที่ต้องการขึ้น ผมสร้าง block ให้อยู่ใน column เดียวกัน ซึ่งแต่ละ block speed ไม่เท่ากัน สิ่งที่ควรจะเป็นคือมันร่วงลงไปตำแหน่งที่ต้องการได้ปกติ เพราะว่าไม่มีการขวางกัน ซึ่งก็ถูกต้อง

#[test]
fn block_drop_block() {
    let mut app = setup();

    app.add_systems(
        Update,
        (
            block_fall_intent,
            block_position_resolve,
            block_position_commit,
        )
            .chain(),
    );

    let entity1 = app.world_mut().spawn((Block(0), BlockSlot(4))).id();
    let entity2 = app.world_mut().spawn((Block(1 << 4), BlockSlot(0))).id();

    app.update();

    let grid = app.world().resource_ref::<GridTable>();

    let index_e1 = grid.index_from_coord(&IVec2::new(0, 2)) as u64;
    let index_e2 = grid.index_from_coord(&IVec2::new(0, 1)) as u64;

    assert_eq!(
        app.world().get::<BlockSlot>(entity1).unwrap().deref(),
        &index_e1
    );

    assert_eq!(
        app.world().get::<BlockSlot>(entity2).unwrap().deref(),
        &index_e2
    );

    assert!(grid.claimable(0));
    assert!(!grid.claimable(index_e1 as usize));
    assert!(!grid.claimable(index_e2 as usize));
}

พอเอาชื่อ function มาอ่านแล้ว ก็รู้สึกว่าตัวเองตั้งชื่อไม่ถูกนะ แต่เอาเป็นว่านี่แหละคือ test ที่ผมต้องการ สร้าง block เหมือนกับเคสก่อนหน้า แต่ว่าให้ตัวด้านบนมีความเร็วมากกว่าตัวด้านล่าง สิ่งที่ผมต้องการคือ block ด้านบนจะติดและเลื่อนลงมาอยู่บนตำแหน่งของ block ตัวล่างแทนเพราะว่าตำแหน่งที่ต้องการไปมันไม่ว่าง … ใช่แล้วครับเทสนี้รันไม่ผ่าน เยี่ยมมม

กลับมาดูโค้ดกัน

pub fn block_position_resolve(..) {
    let mut grid = grid.0.clone();

    for (entity, block_slot, move_intent) in blocks
        .iter_mut()
        .sort_by_key::<&ฺBlockMoveIntend, _>(|a| a.0)
        .rev()
    {
        ..
    }

ฮืมม คือผมในตอนนี้น่ะรู้คำตอบอยู่ละ เพราะว่าแก้มันไปแล้ว แล้วผมคิดว่าผมเองก็ไม่ได้ให้ context ไว้พอให้คนอ่านช่วยหาได้ว่ามันผิดตรงไหน แต่ขอเล่าคร่าวๆ กับสิ่งสำคัญของวิธีการทำงานที่ผมต้องการคือ ผมจะไล่ดูทุก block แล้วพยายามเลื่อนตำแหน่งมันลงทีละ 1 จนกว่าจะครบจำนวนที่มันร่วงลงไปได้ และผมจะ ไล่หาจาก block ตำแหน่งที่อยู่ล่างสุด ไล่กลับไปด้าบบน เพราะว่า block ทุกตัวจะร่วงลงมาใช่ไหมครับ ตัวที่จะร่วงได้โดยไม่มีปัญหาอะไรเลยก้คือตัวที่อยู่ล่างสุดนั่นเอง เพราะการันตีได้ว่าจะไม่มีใครขวาง ซึ่งอีกอย่างนึงคือ block บนๆ มันต้องการตำแหน่งของ block ด้านล่างเพื่อให้มันร่วงลงได้ถูกต้อง ดังนั้นการคำนวณ block ด้านล่างก่อนก็จะดีกว่ามาคำนวณย้อนไปมาครับ

เอาล่ะ ไม่รู้ว่าจะมีใครเดาออกไหมจาก code ที่เห็นแล้วก็ requirement ที่ผมต้องการ แต่ว่าถ้าคุณเดาออกก็คือเก่งมากครับ เป็นผมก็คงไม่รู้แต่ก็เพราะว่าผมเขียนเองผมเลยเข้าใจว่าแต่ละจุดมันคืออะไร

    .iter_mut()
    .sort_by_key::<&ฺBlockMoveIntend, _>(|a| a.0) // <<< problem
    .rev()

ตัวการอยู่ที่นี่เอง เพราะว่าผมต้องการไล่เรียงจากตำแหน่งด้านล่างขึ้นมาด้านบน ดังนั้นผมต้องทำการเรียงตำแหน่งมันก่อนแล้วค่อยไล่ขึ้นไป แต่ว่าตัวที่ใช้ในการเรียงเนี่ย มันใช้ตำแหน่งที่มันต้องการไปในการเรียง (move intend) ไม่ใช่ตำแหน่งที่มันอยู่ตอนนี้ เลยทำให้การคำนวณมันผิดพลาดนั่นเอง

pub fn block_position_resolve(..) {
    let mut grid = grid.0.clone();

    for (entity, block_slot, move_intent) in blocks
        .iter_mut()
        .sort_by_key::<&ฺBlockSlot, _>(|a| a.0)
        .rev()
    {
        ..
    }

เอาล่ะ ก็ได้เวลาแก้โ้ค้ดซะ ทีนี้ก็ลองรัน test ดูอีกที ซึ่งก็ผ่านแล้วครับบบ

นี่แหละความคือความสุดยอดของ Unit test ผมสามารถสร้างสถานการณ์ที่ต้องการได้แบบควบคุมทุกตัวแปร และทดสอบมันได้ทันที ไม่ต้องเตรีบมข้อมูล และรอเวลามากมาย ก็สามารถสร้างความมั่นใจให้ตัวโปรแกรมเราได้อย่างน่าเหลือเชื่อ ยิ่งไปกว่านั้นมันยังมีประโยชน์ในภายหลังอีกด้วย ถ้าผมต้องการ optimize หรือ refactor ส่วนใด ผมสามารถทำได้เลยโดยไม่ต้องกังวลเพราะว่า ผมมี test เหล่านี้คอยทดสอบพฤติกรรม (Behavior) ของมันให้อยู่แล้ว เพราะแบบนี้ มันควรที่จะไม่ทำลายของเดิม (ตราบใดที่ structure มันยังเหมือนเดิมอ่ะนะ แต่ก็ถึงจะต้องรื้อ มันก็จะมีสิ่งนี้ที่คอยให้เรา aware อยู่ดี)

อันนี้อาจะนอกเรื่องหน่อยแต่ว่าเรื่องของ Unit test จริงๆ แล้วมันค่อนข้างที่จะมีข้อถกเถียงกันอยู่ใช่น้อย หลายๆ คนมักจะใช้ชื่อ Unit แล้วตีความมันเป็นส่วนที่เล็กที่สุดเสมอ ซึ่งก็คือ function แต่โดยสว่นตัวของผมเองแล้ว มันต่างบริบทกันหรือแม้ต่างทีมก็จะกำหนดคำว่า unit ด้วยหน่วยที่ต่างกัน ซึ่งก็ไม่มีใครผิดและถูกนะ (ความเห็นของผมเอง)

แล้ว Unit test คืออะไรสำหรับผม

สำหรับผม Unit test ไม่ใช่เรื่องของการทดสอบ function เล็กๆ จุดใดจุดเดียว แต่มันคือการสร้างสถานการณ์ที่ควบคุมได้
เพื่อให้เรามั่นใจในพฤติกรรมของระบบ

มันช่วยลดความเหนื่อย
ลด manual loop
และทำให้ refactor เป็นเรื่องที่ไม่ต้องกลัว

ยิ่งถ้าการทำงานเป็นทีมด้วยแล้ว มันจะไม่ได้ช่วยแค่เพิ่มความมั่นในของคุณเอง แต่ว่ามันสามารถช่วยเพิ่มความมั่นใจได้ทั้งทีมด้วย

แต่ก็ไม่ใช่ว่าเราจะทำไม่มีการ manual test เลยนะครับ ในหลายๆ บริบทมันก็มีความจำเป็นอยู่ดี

  • Manual test → มั่นใจสูงเพราะทดสอบกับของจริง แต่ใช้เวลา และทำซ้ำยาก
  • Automated test → สร้างความมั่นใจได้รวดเร็ว และทำซ้ำได้ไม่จำกัด

และนี่ก็เป็นการผจญภัยของผมกับ Bevy ขอบคุณทุกคนนะครับที่อ่านมาถึงจุดนี้ ไว้เจอกันใหม่

Unit test อาจจะไม่ได้ทำให้คุณเป็นโปรแกรมเมอร์ที่เก่งขึ้นทันที แต่มันทำให้คุณกล้าที่จะพัฒนาโค้ดของตัวเองต่อไปโดยไม่กลัว