test double session จากงาน code Mania ครั้งที่ 11

เมื่อวันเสาร์ที่ 27 ก.พ. 2016 ที่ผ่านมา ได้มีโอกาสเข้าร่วมงาน Code Mania ครั้งที่ 11 ซึ่งจัดโดยสมาคมโปรแกรมเมอร์ไทย

ทั้งนี้ต้องขอบคุณตั๋วฟรีจากทางสมาคม และน้อง Max (Issaret Max Prachitmutita) ที่เป็นธุระให้อย่างดี ขอขอบคุณมา ณ โอกาสนี้

image title

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

สำหรับผู้อ่านที่สนใจ session เช้า และสรุป session ต่างๆ ผมขอแนะนำบทความนี้เลยครับ

Code Mania 11: Raise the Bar ของคุณ Kan Ouivirach

โดยผมขอเริ่มบทความจาก session test double

##Test Double Session

ต้องขอขอบคุณวิทยากรสำหรับ session นี้คือคุณเก๋ Nattanicha Phatharamalai ที่ไขความกระจางเกี่ยวกับ test double

ภาพบรรยากาศในห้องบรรยาย

image title

ก่อนถึงคำว่า test double ผมขออธิบายถึงหลักการง่ายๆ ของ unit test ก่อนนะครับ

unit test คือการเขียนคำสั่ง หรือ code ไปทดสอบการทำงานของ code หลักที่เราสร้างขึ้น หรือ production code นั่งเอง ว่าทำงานถูกต้องตามที่เราต้องการ

ลักษณะสำคัญของ unit test คือ

  • ต้องทำงานได้ไว เพราะเราจะต้อง run unit test บ่อยมาก หากต้องรอผลลัพธ์การที่ทำงานที่นาน แบบนี้ไม่ใช่ unit test ทั้งยังทำให้เกิด bottle neck ในงาน ไม่เพิ่ม productivity
  • ทำซ้ำได้และผลลัพธ์ที่ได้เหมือนเดิม ไม่มี side effect

แต่แน่นอนว่าใน code ของเรา ย่อมมีส่วนที่ทำงานได้ช้ากว่าส่วนอื่นๆ เช่น access database, web service, sending email

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

กล่าวคือ test double หมายถึง การสร้างตัวแทนของ object จริง เพื่อใช้งานใน unit test มีที่มาจากคำว่า stunt double ที่หมายถึงตัวแสดงแทนในภาพยนต์

test double เป็นเพียง technical term แต่เมื่อพูดถึงการนำไปใช้งานจริง การสร้างตัวแทนของ object ก็แบ่งแยกย่อยออกไปได้อีก ดังนี้

  • dummy
  • stub
  • spy
  • mock
  • fake

ดังนั้นเวลาจะที่อ้างถึง dummy, stub, mock, spy และ fake แบบรวมๆ ไม่เจาะจงก็เรียกว่า test double จริงๆ แล้ว test double มักถูกเรียกอีกชื่อว่า mock ถึงถือว่าไม่ผิด แต่ไม่เป็นทางการมากกว่า เพราะจริงๆ แล้ว mock เป็นเพียงวิธีการหนึ่งของ test double

ดังนั้นเราไปทำความรู้จักกับความหมายของ test double แต่ละชนิดกันเลยดีกว่า

###dummy สร้างขึ้นมาเพื่อช่วยในการ set up test แต่ไม่ได้สนใจ ใส่ใจการทำงานของ dummy เลย เช่นการสร้าง dummy object เพื่อใช้เป็น argument ของ method หรือ constructor

stub

ใช้เมื่อต้องการทำให้ object ที่ถูกแทนที่ทำงานบางอย่าง เพื่อให้เราสามารถทำการทดสอบส่วนอื่นๆ ที่สนใจได้

spy

เป็น stub ที่เพิ่มความสามารถบางอย่าง เช่น มีตัวแปรเก็บว่า method ถูกเรียกใช้ไปแล้วหรือไม่

mock

สนใจพฤติกรรมของ method ว่ามีการ call หรื่อไม่ กี่ครั้ง หรือทดสอบการรับค่า argument ของ method

fake

สร้าง object ใหม่ที่มีส่วน business behavior อยู่ด้วย มีส่วนการทำงานจริง

เพื่อความเข้าใจ ผมได้นำแนวคิดจาก session ของคุณเก๋ และบทความของพี่ปุ๋ยใช้เป็นแนวทาง เพื่อสร้างระบบ NotificationService ง่ายๆ เป็นรูปแบบการทำงานร่วมกันของ object ต่างๆ เพื่อทำการส่ง email ไปแจ้งผู้ใช้ ตัว code หลักเขียนด้วย Java แต่ unit test เขียนด้วย Groovy และ Spock ครับ หากใครสนใจรายละเอียดเบื้องต้นเกี่ยวกับ Spock สามารถไปอ่านเพิ่มเติมตามนี้ได้เลย

เลิก manual test แล้วมาเขียน unit test Java Project ด้วย Spock กันเถอะ

เรามาดู class หลักของระบบ NotificationService กันดีกว่าครับ

NotificationService.java

package com.codesanook.example;

public class NotificationService {

    private EmailClient emailClient;
    private NotificationInputValidator validator;

    public NotificationService(EmailClient emailClient,
                               NotificationInputValidator validator) {
        this.emailClient = emailClient;
        this.validator = validator;
    }

    public String removeHtmlTag(String input) {
        return input.replaceAll("<[^>]*>", "");
    }

    public Email composeEmail(String from, String to, String subject, String body) {
        if (!validator.validateEmailInput(from, to, subject, body)) {
            throw new IllegalStateException("invalid email input");
        }

        Email email = new Email();
        email.setFrom(from);
        email.setTo(to);
        email.setSubject(subject);
        email.setBody(body);
        return email;
    }


    public boolean notifyByEmail(String from, String to, String subject, String body) {
        subject = removeHtmlTag(subject);
        body = removeHtmlTag(body);
        Email email = composeEmail(from, to, subject, body);
        return emailClient.sendEmail(email);
    }


}

อธิบาย

  • class สำหรับส่งเตรียม email และส่ง email ไปยังผู้ใช้
  • ต้องการ EmailClient class ที่ทำหน้าที่สำหรับสิ่ง email จริงๆ ผ่าน SMTP และ NotificationInputValidator สำหรับการ validate input
  • method removeHtmlTag สำหรับแยก HTML tag ต่างๆ ออกไปจาก subject และ body ของ email
  • method composeEmail สำหรับสร้าง email object เพื่อส่งให้ EmailClient ไปใช้งานต่อ
  • notifyByEmail เป็น method หลักที่ใช้สำหรับแจ้ง user ผ่านทาง email

ต่อไปเราก็จะมาทำการ test กันเพื่อความเข้าใจ test double ในแต่ละแบบ

dummy

สร้างขึ้นมาเพื่อช่วยในการ set up test แต่ไม่ได้สนใจ ใส่ใจการทำงานของ dummy เลย เช่นการสร้าง dummy object เพื่อใช้เป็น argument ของ method หรือ constructor

ถ้าเราจะ test removeHtmlTag method สังเกตว่า method นี้ ไม่ได้ต้องการใช้ EmailClient หรือ NotificationInputValidator object เลย แต่การสร้าง NotificationService object จำเป็นต้องใช้ EmailClient และ NotificationInputValidator ส่งเป็น constructor ดังนั้น เมื่อเกิดกรณีเช่นนี้ เราก็สร้าง EmailClient และ NotificationInputValidator เป็น dummy object ได้เลย

NotificationServiceRemoveHtmlTagSpec.groovy

package com.codesanook.example.test

import com.codesanook.example.Email
import com.codesanook.example.EmailClient
import com.codesanook.example.NotificationInputValidator
import com.codesanook.example.NotificationService
import spock.lang.Specification


class NotificationServiceRemoveHtmlTagSpec extends Specification {

    def "HTML string should be remove"() {

        given:
        def emailClient = new DummyEmailClient()
        def inputValidator = new DummyInputValidator()
        NotificationService notificationService =
                new NotificationService(emailClient, inputValidator)

        def htmlString = "<h1>hello</h1> <p>world</p>"

        when:
        def removedHtmlTag = notificationService.removeHtmlTag(htmlString)

        then:
        removedHtmlTag == "hello world"
    }
}


class DummyInputValidator extends NotificationInputValidator {

    @Override
    public boolean validateEmailInput(String from, String to, String subject, String body) {

        throw new IllegalStateException("should not be called");
    }
}

class DummyEmailClient implements EmailClient {

    @Override
    boolean sendEmail(Email email) {
        throw new IllegalStateException("should not be called");
    }
}

อธิบาย

  • ใน code นี้เราสร้าง unit test ด้วย Spock framework pattern ในการเขียนจะเป็น
  • given ส่วนสำหรับ setup
  • when ส่วนที่สั่งให้ code ที่ต้องการ test ทำงาน
  • then ตรวจสอบผลลัพธ์การทำงาน
  • สร้าง dummy class DummyInputValidator และ DummyEmailClient

stub

ใช้เมื่อต้องการทำให้ object ที่ถูกแทนที่ทำงานบางอย่าง เพื่อให้เราสามารถทำการทดสอบส่วนอื่นๆ ที่สนใจได้

NotificationServiceComposeEmailSpec.groovy

package com.codesanook.example.test

import com.codesanook.example.Email
import com.codesanook.example.EmailClient
import com.codesanook.example.NotificationInputValidator
import com.codesanook.example.NotificationService
import spock.lang.Specification


class NotificationServiceComposeEmailSpec extends Specification {

    def "valid email input, valid email object should return"() {

        def emailClient = new DummyEmailClient()
        NotificationInputValidator inputValidator = Stub();
        inputValidator.validateEmailInput(_, _, _, _) >> true;

        NotificationService notificationService =
                new NotificationService(emailClient, inputValidator)

        def from = "abc@mail.com"
        def to = "xyz@mail.com"
        def subject = "Hello"
        def body = "Hello World"
        Email email = notificationService.composeEmail(from, to, subject, body)

        expect:
        email.from == from
        email.to == to
        email.subject == subject
        email.body == body

    }

อธิบาย

  • เนื่องจาก การ test composeEmail method มีการเรียกใช้งาน validateEmailInput ของ object NotificationInputValidator
  • เราต้องการ test ว่า composeEmail สร้าง email object ถูกต้องหรือไม่
  • เราสามารถ stub NotificationInputValidator ให้ทุกครั้งที่ถูกเรียกใช้ return ค่า true ออกไป ด้วยการสร้าง stub object ดังนี้ NotificationInputValidator inputValidator = Stub();
  • และกำหนดให้ method validateEmailInput รับค่าใดก็ตาม ก็ return ค่า true ออกไปดังนี้ inputValidator.validateEmailInput(_, _, _, _) >> true;
  • หลังจาก stub NotificationInputValidator เราก็สามารถทดสอบส่วนจริงๆ ของการ composeEmail ได้

spy

เป็น stub ที่เพิ่มความสามารถบางอย่าง เช่น มีตัวแปรเก็บว่า method ถูกเรียกใช้ไปแล้วหรือไม่

NotificationServiceComposeEmailSpec.groovy

package com.codesanook.example.test

import com.codesanook.example.Email
import com.codesanook.example.EmailClient
import com.codesanook.example.NotificationInputValidator
import com.codesanook.example.NotificationService
import spock.lang.Specification


class NotificationServiceComposeEmailSpec extends Specification {

    def "validateEmailInput called once"() {
        given:
        EmailClient emailClient = new DummyEmailClient()
        NotificationInputValidator inputValidator = Spy()

        NotificationService notificationService = new NotificationService(emailClient, inputValidator)


        def from = "abc@mail.com"
        def to = "xyz@mail.com"
        def subject = "Hello"
        def body = "Hello World"
        when:
        notificationService.composeEmail(from, to, subject, body)

        then:
        1 * inputValidator.validateEmailInput(_, _, _, _)
    }



}


อธิบาย

  • สร้าง Spy object ด้วย NotificationInputValidator inputValidator = Spy()
  • ใน then block เราทดสอบว่า validateEmailInput ถูกเรียกใช้หนึ่งครั้ง ด้วยคำสั่ง 1 * inputValidator.validateEmailInput(_, _, _, _)

mock

สนใจพฤติกรรมของ method ว่ามีการ call หรื่อไม่ กี่ครั้ง ทดสอบการรับ parameter ของ method

NotificationServiceSendEmailSpec.groovy


package com.codesanook.example.test

import com.codesanook.example.Email
import com.codesanook.example.EmailClient
import com.codesanook.example.NotificationInputValidator
import com.codesanook.example.NotificationService
import spock.lang.Specification


class NotificationServiceSendEmailSpec extends Specification {

    def "valid email input, sendEmail called once"() {
        given:
        EmailClient emailClient = Mock()
        NotificationInputValidator inputValidator = Stub()
        inputValidator.validateEmailInput(_, _, _, _) >> true

        NotificationService notificationService =
                new NotificationService(emailClient, inputValidator)

        def from = "abc@mail.com"
        def to = "xyz@mail.com"
        def subject = "Hello"
        def body = "Hello World"

        when:
        notificationService.notifyByEmail(from, to, subject, body)

        then:
        1 * emailClient.sendEmail({ Email email ->
            email.from == from
            email.to == to
            email.subject == subject
            email.body == body
        })
    }
}

  • สร้าง mock object ด้วย EmailClient emailClient = Mock()
  • สร้าง stub object ให้ validateEmailInput return ค่า true เสมอ
  • ส่วนของ then block เราตรวจสอบว่า sendEmail ถูกเรียกใช้งานหนึ่งครั้ง พร้อม check argument ที่ส่งมาในรูปแบบของ closure

fake

สร้าง object ใหม่ที่มีส่วน business behavior ด้วย มีส่วนการทำงานจริง

NotificationServiceSendEmailSpec.groovy

package com.codesanook.example.test

import com.codesanook.example.Email
import com.codesanook.example.EmailClient
import com.codesanook.example.NotificationInputValidator
import com.codesanook.example.NotificationService
import spock.lang.Specification


class NotificationServiceSendEmailSpec extends Specification {


    def "valid email, send email return true"() {
        given:
        EmailClient emailClient = new FakeEmailClient();
        NotificationInputValidator inputValidator = Stub()
        inputValidator.validateEmailInput(_, _, _, _) >> true

        NotificationService notificationService =
                new NotificationService(emailClient, inputValidator)

        def from = "abc@mail.com"
        def to = "xyz@mail.com"
        def subject = "Hello"
        def body = "Hello World"

        when:
        def sentResult = notificationService.notifyByEmail(from, to, subject, body)

        then:
        sentResult == true
    }


    class FakeEmailClient implements EmailClient {


        @Override
        boolean sendEmail(Email email) {
            println "sent email from ${email.from} to ${email.to}\n" +
                    "subject ${email.subject} body ${email.body}";
            return true;
        }
    }

}

อธิบาย

  • เราสร้าง class FakeEmailClient ขึ้นมา โดยการสืบทอดจาก class EmailClient และ override sendEmail เพื่อ implement ส่วนของ business logic ที่เราต้องการ ในที่นี้ คือ การ log ข้อมูลออกมาที่หน้าจอ
  • และนำ FakeEmailClient ไปใช้ใน unit test
  • เราสามารถประยุกต์ใช้งานอื่นๆ สำหรับ Fake เช่น in-memory database เพื่อใช้ใน test

ส่งท้าย

หวังว่าผู้อ่าน จะได้เข้าใจความหมายและเห็นวิธีการต่างๆ ของ test double มากขึ้นนะครับ

ถ้าใครมีคำถามข้อสงสัยใดๆ เขียน comment มาได้เลย

สุดท้ายต้องขอขอบคุณวิทยากรคุณเก๋ และพี่ปุ๋ยสำหรับข้อมูลที่นำมาประกอบเป็นบทความนี้ครับ

reference

download source code