Jitrak Blog

Test double

รู้จัก Test double แต่ละประเภท และการใช้งาน

11 Sep 2023 14:02

Written by: Yosapol Jitrak

Commonly refered to as mocks
Tags:

Uni test

Automation

Test double

CSharp

ก่อนหน้านี้ผมกล่าวถึงการทำ TDD อย่างถูกต้อง ซึ่งมีการกล่าวถึง Test double อยู่เพียงเล็กน้อย ในบทความนี้ผมจะมากล่าวถึง Test double อย่างละเอียดครับ โดยยกตัวอย่างเป็น C# ด้วย NUnit แต่หลักการสามารถใช้กับภาษาอื่นได้เช่นกันครับ

Test double คืออะไร

ปกติการทำ Software คงเลี่ยงยากที่จะไม่ยุ่งกับ Dependencies หรือ Third party เช่น API, Database, Time, Classes, Sharded Properties เป็นต้น

Software architectural pattern Credit image: 10 Common Software Architectural Patterns in a nutshell

Test-SUT-Dependency

ซึ่งเราจะแทนที่ Dependencies ด้วย Test double ตามรูปนี้ครับ

SUT ย่อมาจาก System Under Test นะครับ

Test-SUT-TestDouble

ยกตัวอย่าง ให้เห็นภาพชัดขึ้น Fake account DAO Credit image: Test Doubles — Fakes, Mocks and Stubs.

แทนที่เราจะต่อ AcountDAO ซึ่งต่อกับ Database จริง เราก็มาต่อกับ FakeAccountDAO แทน คิดว่าพอจะเห็นภาพกันมากขึ้นนะครับ เดี๋ยวเราจะมาดู Test double แต่ละประเภทกันครับ

Test double แต่ละประเภท

Commonly refered to as mocks Credit image: Mocking in python

ตัวอย่างทั้งหมดนี้เอามาจาก Unit testing at the speed of light with Unity Test Tools ซึ่งตัว Blog เก่ามากแล้ว รูปภาพที่ได้ทำการยกตัวอย่างไว้แสดงได้ไม่ครบถ้วน

Dummy Object

ใช้เมื่อต้องการใช้ Object type นั้น อย่าง Interface หรือ Class โดยไม่ได้สนใจ Implement ข้างใน แต่ต้องส่ง Object ไปให้ Method อื่น ๆ ใช้งาน

public interface ISpaceShip
{
  int AvailableWeaponSlots { get; }
  void Equip(IWeapon weapon);
}
public class DummyWeapon : IWeapon
{
  public Shot[] Shoot()
  {
    return null;
  }
}
[Test]
public void NoWeaponSlotsAvailable_AfterWeaponIsEquiped()
{
  // Arrange
  SpaceShip spaceShip = SpaceShipWithSingleWeaponSlot();
  IWeapon weapon = new DummyWeapon();

  // Act
  spaceShip.Equip(weapon);

  // Assert
  var noWeaponSlotsAvailable = spaceShip.AvailableWeaponSlots == 0;
  Assert.That(noWeaponSlotsAvailable);
}

จะเห็นว่าตัว DummyWeapon ไม่ได้ Implement อะไรเป็นพิเศษเลย เพียงแค่ให้สามารถสร้าง Object จาก Interface IWeapon ได้ แล้วส่งไปให้ Method Equip ของ SpaceShip ใช้งานได้

แต่ดูวิธีการสร้าง Class DummyWeapon มือเป็นวิธีการที่ถึกไปหน่อย ถ้า Interface IWeapon ไม่ได้มี Method แค่นี้ จึงแนะนำให้ใช้ Mocking libraries ที่มีการสร้าง Dummy Object ให้เราเลยครับ ในที่นี้ผมจะใช้ NSubstitute ภาษาอื่นที่ไม่ใช่ C# ก็มี Mocking libraries ให้ใช้งานเช่นกันครับ

NSubstitute

[Test]
public void NoWeaponSlotsAvailable_AfterWeaponIsEquiped()
{
  // Arrange
  SpaceShip spaceShip = SpaceShipWithSingleWeaponSlot();
  IWeapon weapon = NSubstitute.Substitute.For<IWeapon>();
  // Act
  spaceShip.Equip(weapon);
  // Assert
  var noWeaponSlotsAvailable = spaceShip.AvailableWeaponSlots == 0;
  Assert.That(noWeaponSlotsAvailable);
}

พอใช้ Mocking libraries แล้ว ก็จะเห็นว่าไม่ต้องสร้าง DummyWeapon class เองแล้ว

Test Stub

ใช้เมื่อต้องการบังคับ Return value จาก Dependency ครับ

class FunctionalWeaponStub: IWeapon
{
  public Shot[] Shoot()
  {
    return new[] { new Shot(0, 0, 0) };
  }
}
[Test]
public void SpaceShipShootsAtLeastOneShot_WhenFunctionalWeaponIsEquiped()
{
  // Arrange
  var ship = SpaceShipWithSingleWeaponSlot();
  ship.Equip(new FunctionalWeaponStub());

  // Act
  var round = ship.Shoot();

  // Assert
  var roundContainsOneShot = round.Length == 1;
  Assert.That(roundContainsOneShot);
}

แน่นอนว่าการเขียน Class FunctionalWeaponStub เป็นอะไรที่เปลืองแรงเช่นเคย ก็ใช้ NSubstitute แทนได้เช่นกันครับ

[Test]
public void SpaceShipShootsAtLeastOneShot_WhenFunctionalWeaponIsEquiped()
{
  // Arrange
  var ship = SpaceShipWithSingleWeaponSlot();

  var functionalWeaponStub = Substitute.For<IWeapon>();
  functionalWeaponStub.Shoot().Returns(new[] { new Shot(0, 0, 0) });
  ship.Equip(functionalWeaponStub);

  // Act
  var round = ship.Shoot();

  // Assert
  var roundContainsOneShot = round.Length == 1;
  Assert.That(roundContainsOneShot);
}

ยกตัวอย่าง Stub อีกตัวหนึ่งครับ

var randomNumberService = Substitute.For<IRandomNumberService>();
randomNumberService.Range(0, 0).ReturnsForAnyArgs(0, 2, 8);

จากตัวอย่าง Code นี้คือตัวอย่างการบังคับให้ Random คืนค่าตามที่ต้องการได้ โดยเรียกครั้งแรกจะคืนค่า 0 ออกมา และเมื่อเรียกครั้งถัดไปจะคืนค่า 2 ออกมา และครั้งที่สามจะคืนค่า 8 ออกมา

Test Spy

เอาไว้ติดตามหรือบันทึกการเรียกใช้งานของ Dependency ครับ

public class SpaceShipSpy : SpaceShipDummy
{
  public int HitsCount { get; set; }

  public override void AcceptIncomingShots(IEnumerable<Shot> shots)
  {
    HitsCount+=shots.Count();
  }
}
[Test]
public void OpponentGetHitInAnEncounter_OnAttack()
{
  // Arrange
  var opponent = new SpaceShipSpy();
  var player = SpaceShipWithTwoFunctionalWeaponStubs();
  var randomNumberService = AlwaysMaxRandomNumber();
  var encounter = new Encounter(player, opponent, randomNumberService);

  // Act
  encounter.Attack();

  // Assert
  Assert.That(opponent.HitsCount, Is.EqualTo(2));
}

ซึ่งเราสามารถแทนการสร้าง SpaceShipSpy ได้ด้วย Code ข้างล่างนี้ครับ

int hitCount = 0;
var opponent = Substitute.For<ISpaceShip>();
opponent.AcceptIncomingShots (Arg.Do<IEnumerable<Shot>>(x => hitCount+=x.Count()) );

Mock Object

เป็น Test Spy ที่มีการตรวจสอบการเรียกใช้งานของ Dependency ครับ

[Test]
public void EachWeaponShoots_WhenSpaceShipShootIsCalled()
{
  // Arrange
  var weapon1 = Substitute.For<IWeapon>();
  var weapon2 = Substitute.For<IWeapon>();

  var ship = new SpaceShip(2, 0);
  ship.Equip(weapon1);
  ship.Equip(weapon2);

  // Act
  ship.Shoot();

  // Assert
  weapon1.Received(1).Shoot();
  weapon2.Received(1).Shoot();
}

จากตัวอย่าง Code ข้างบน จะเป็นการเช็คว่า weapon1 และ weapon2 ถูกเรียกใช้งาน Method Shoot 1 ครั้งหรือไม่

[Test]
public void EachWeaponGetsReloaded_AfterItIsShot()
{
  // Arrange
  var weapon1 = Substitute.For<IWeapon>();
  var weapon2 = Substitute.For<IWeapon>();

  var ship = new SpaceShip(2, 0);
  ship.Equip(weapon1);
  ship.Equip(weapon2);

  // Act
  ship.Shoot();

  // Assert
  Received.InOrder(() =>
    {
      weapon1.Shoot();
      weapon2.Shoot();
      weapon1.Reload();
      weapon2.Reload();
    }
  );
}

จากตัวอย่าง Code ข้างบน จะเป็นการเช็คว่า Method ต่าง ๆ ถูกเรียกใช้งานตามลำดับหรือไม่

Fake Object

เป็น Test double ที่มี Logic อยู่ข้างใน ซึ่งจำลองการทำงานเหมือนระบบจริง และควรพยายามเลี่ยง Fake object ถ้าเป็นไปได้มากที่สุด ขนาดลุง Bob ยังบอกว่าทำ Software ใน 30 ปีที่ผ่านมา เคยใช้ Fake oject เพียงแค่ครั้งเดียวเอง [แปล] The Little Mocker ของ Uncle Bob

Code นี้เป็น Java นะครับ

public class AcceptingAuthorizerStub implements Authorizer {
   public Boolean authorize(String username, String password) {
       return username.equals("Bob");
   }
}

ย้ำอีกครั้งนะครับตัวอย่างเกือบทั้งหมดนี้เอามาจาก Unit testing at the speed of light with Unity Test Tools ผมไม่ได้เขียนเองครับ

จบไปแล้วนะครับ กับ Test double บทความหน้า ผมจะมาต่อเรื่อง npm package ที่ช่วยให้เขียน Test double กับ Jest ได้ง่าย

ไปละครับ Bye Bye