Test & Test Double

Posted by Elizabeth Huang on Wed, May 18, 2022

測試的目的

不知道大家有沒有那種,隨著專案功能越來越多,一不小心就把舊的功能改壞的經歷?

或是手動重複做相同的測試,花費大量時間呢?

測試可以幫助減少這些問題,除此之外還有:

  1. 測試結果是否符合預期
  2. 提供一個範例:假如這個功能很複雜,查看測試可以快速了解這個功能的用處
  3. 提供類似規格書的功能:查看test就可以大概知曉這個class開發了哪些功能
  4. 方便重構或優化:重構以後還可以保證功能沒有壞掉

測試種類

事先聲明! 我覺得有些專有名詞的定義不是很清晰,有些人的觀點甚至有點衝突,所以這篇都是我查閱許多資料和教學,基於個人理解寫出來的

測試的種類有很多,但這篇只會講我接觸過的三種

  • Unit test:專注測試一個class
  • Feature test:測試一個功能,確保符合驗收標準
  • Browser test:瀏覽器模擬使用者操作

Feature Test相較Unit Test更注重是否符合需求,Unit Test比較能定位問題在哪裡

打個比方,電腦無法開機有很多原因,一個一個單元測試每個東西才能定位原因

Unit Test 跟Feature Test 同樣重要

以測試覆蓋率來講,好像Unit Test寫完就好了?但Feature Test一樣很重要,舉個搞笑的例子,之前我在Laracast這篇貼文看到一個gif

烘手機和垃圾桶

烘手機和垃圾桶的單元測試都沒問題,組合在一起就出事(吹葉機?)

所以我們需要功能測試確保各個部件組合出來的功能正常

Unit Test

測試的結構

我通常會用Given-When-Then的結構寫測試

Given表示前情提要,When表示做了什麼事,Then表示預期的結果

舉一個屬於feature test的例子:

  • Given:我是會員
  • When:進入首頁
  • Then:可以在文章下方看到喜歡、收藏按鈕

Test Double

之前我總是搞不清楚這是什麼,在這裡分享我自己的理解

Double 是測試的某種替代品,我們不用關心裡面怎麼實作,它是

假的

Double常用於第三方的API,例如丟檔案到S3、寄信等,或是這個API回應時間很長的情況,或是這個API需要付錢等等

當然這種寄信功能我們確實要測試正不正常,但不需要每次都測試,因此可以用Double代替

來看一個PHP Test的例子:

這是一個News class,建立一則新聞的時候,會使用Downloader去S3下載一張圖片作為封面圖

 1// News.php
 2namespace App;
 3
 4class News
 5{
 6    protected $coverImage = null;
 7
 8    public function __construct(ImageDownloader $downloader)
 9    {
10        $this->downloader = $downloader;
11    }
12
13    public function create()
14    {
15        $this->coverImage = $this->downloader->downloadCoverImage();
16    }
17
18    public function hasCoverImage()
19    {
20        return !empty($this->coverImage);
21    }
22}
23
24// ImageDownloader.php
25namespace App;
26
27class ImageDownloader
28{
29    public function downloadCoverImage()
30    {
31        // Download a random image.
32    }
33
34    public function downloadIcon()
35    {
36        // Download a icon.
37    }
38}

再來看測試,遵循剛才說的Given-When-Then方法:

  • Given:建立ImageDownloader和News class
  • When:建立一篇新聞
  • Then:斷言這篇新聞有封面圖
 1// NewsTest.php
 2namespace Tests;
 3
 4use App\News;
 5use App\ImageDownloader;
 6use PHPUnit\Framework\TestCase;
 7
 8class NewsTest extends TestCase
 9{
10    /** @test */
11    public function download_a_image_when_news_created()
12    {
13        $downloader = new ImageDownloader;
14        $news = new News($downloader);
15        $news->create();
16        $this->assertTrue($news->hasCoverImage());
17    }
18}

用Dummy/Fake改寫

上面提到Downloader會去S3下載圖片,但我又不希望每個新聞測試都真的下載一張圖

現在用Dummy,或者說Fake來改寫,建立一個假的class取代原本的,下載圖片的function永遠只會回傳fake cover

 1namespace Tests;
 2
 3class FakeImageDownloader
 4{
 5    public function downloadCoverImage()
 6    {
 7        return 'fake_cover.jpg';
 8    }
 9
10    public function downloadIcon()
11    {
12        // Download a icon.
13        return 'fake_icon.jpg';
14    }
15}

回頭修改測試 Given的部分,用建立fake物件取代原本的,然後建立News object

 1namespace Tests;
 2
 3use App\News;
 4use PHPUnit\Framework\TestCase;
 5
 6class NewsTest extends TestCase
 7{
 8    /** @test */
 9    public function download_a_image_when_news_created()
10    {
11        $downloader = new FakeImageDownloader;
12        $news = new News($downloader);
13        $news->create();
14        $this->assertTrue($news->hasCoverImage());
15    }
16}

Stub & Mock

每次給一個Test寫一個class,多出新檔案還是挺費事了,可以用Stub或Mock代替

Stub

用createMock建立一個stub,然後設定downloadCoverImage這個function會return stub cover jpg

 1class NewsTest extends TestCase
 2{
 3    /** @test */
 4    public function download_a_image_when_news_created()
 5    {
 6        // stub
 7        $downloader = $this->createMock(ImageDownloader::class);
 8        $downloader->method('downloadCoverImage')->willReturn('stub_cover.jpg');
 9        // downloadIcon根本不會用到,但多寫以下這行也不會噴錯。stub不關心你呼叫了指定的method幾次,甚至不關心是否呼叫過
10        $downloader->method('downloadIcon')->willReturn('stub_icon.jpg');
11        $news = new News($downloader);
12        $news->create();
13        $this->assertTrue($news->hasCoverImage());
14    }
15}
Mock

Mock一樣也是用createMock建立,然後預期downloadCoverImage這個function會被呼叫一次,然後return mock cover jpg

 1class NewsTest extends TestCase
 2{
 3    /** @test */
 4    public function download_a_image_when_news_created()
 5    {
 6        // mock
 7        $downloader = $this->createMock(ImageDownloader::class);
 8        $downloader->expects($this->once())->method('downloadCoverImage')->willReturn('mock_cover.jpg');
 9        // 以下這行會噴錯,因為預期(expects)執行downloadIcon method一次,但實際上沒有
10        $downloader->expects($this->once())->method('downloadIcon')->willReturn('mock_icon.jpg');
11        $news = new News($downloader);
12        $news->create();
13        $this->assertTrue($news->hasCoverImage());
14    }
15}
Stub 和Mock 的區別?

建立新聞的時候不會使用downloadIcon這個function

Stub不關心執行指定的method幾次,甚至不關心是否執行過,因此stub加上下面這行也不會噴錯

而mock,因為mock會預期(expects)執行downloadIcon method一次,但實際上沒有,就會噴錯

我個人認為Mock用在需要嚴謹測試這個被mock的class的時候才需要用到,否則只是一個小改動就要改一堆mock

Browser Test

這裡我用Python Test當範例,因為用Python操作Selenium真的很方便((誠懇

首先我指定使用chrome,然後打開我們的首頁,接下來尋找導航欄的「新聞」,我這裡用css selector尋找元素

之後將由標懸停在這個元素上

最後斷言會在頁面上看到「台股盤勢」四個字

 1import unittest
 2from selenium import webdriver
 3from selenium.webdriver import ActionChains
 4from selenium.webdriver.common.by import By
 5
 6class HomeTest(unittest.TestCase):
 7    def setUp(self):
 8        self.driver = webdriver.Chrome()
 9
10    def test_search_in_python_org(driver):
11        driver = self.driver
12        driver.get("https://www.cnyes.com/")
13        elem = driver.find_element(
14            by=By.CSS_SELECTOR,
15            value='nav ul li a[data-global-ga-label="新聞"]>span'
16        )
17        ActionChains(driver).move_to_element(elem).perform()
18        self.assertIn("台股盤勢", driver.page_source)
19
20    def tearDown(self):
21        self.driver.close()

參考