一堆dependency怎麼測試?把替身叫出來。

我任職的翻譯公司需要線上報價功能。

其中,上傳文件到專案的功能,會同時計算字數、備份檔案到遠端。

程式碼大概是這樣:

upload($file);
        
        $reader = new Reader();
        
        $words = $reader->count($file);
        
        $document = new Document();
        
        $document->remote_file_id = $id;
        
        $document->words = $words;
        
        $document->save();
    }
}

由於這是個核心功能,我希望多寫幾個測試確保它的運作正常。

這樣的function該怎麼測試呢?

我是用Laravel + PHPUnit開發。首先,寫個基本測試,確保function沒有打錯變數名字、語法、不會有莫名exception噴出來:

addDocument($file);
                
        $this->assertGreaterThan(0, $project->words);
        
        $this->assertNotNull($project->remote_file_id);        
    }
    
}

問題來了,這個測試跑得非常慢。因為:

  • 它真的把檔案上傳到資料備份主機
  • 它真的用Reader數字數

我只想確認基本邏輯正常,並不在乎備份、計算字數功能是否正常。(那兩個應該另外測試。)

該怎麼辦呢?

是時候叫替身出來幫忙了!

Test Double

Test Double,我翻譯為「測試替身」,是Gerard Meszaros在2007年提出來的名詞。
它是軟體測試的常見手法之一,會做出類別的替身供測試使用。
又可細分為Fake, Mock, Stub, Dummy… 等等。
我們先不管差別何在,想辦法解決眼前的狗屁就好!

首先,我們得將原本的function改寫,把dependency改用參數傳入:

class Project
{
    public function addDocument($file, $remote, $reader){        
        //....
        //....

        // $remote = new RemoteBackup();
        
        $id = $remote->upload($file);
        
        // $reader = new Reader();
        
        $words = $reader->count($file);
        
        $document = new Document();
        
        $document->remote_file_id = $id;
        
        $document->words = $words;
        
        $document->save();
    }
}

呼叫這個function的相關code,記得改寫成:

$project->addDocument($file, new RemoteBackup(), new Reader());

重點來了,測試怎麼寫?怎麼做出測試替身?

我想要一個不管收到什麼,一律回傳5555當作假id的$remote替身;
還要一個不管收到什麼,都說它計算出6666個字數的$reader替身。

PHPUnit本身就支援Test Double,只要這樣即可:

class ProjectTest extends TestCase {
    
    public function testAddDocument()
    {
        // 先設法做出測試用的$project與$file
        // ...

        $remote = $this->getMockBuilder('RemoteBackup')
                     ->getMock();

        $remote->method('upload')
             ->willReturn(5555);
             
        $reader = $this->getMockBuilder('Reader')
                     ->getMock();

        $reader->method('count')
             ->willReturn(6666);
             
        $project->addDocument($file, $remote, $reader);
                
        $this->assertGreaterThan(0, $project->words);
        
        $this->assertNotNull($project->remote_file_id);        
    }
    
}

搞定!
成功避開遠端備份與計算字數的dependency!

測試完基本邏輯,接著可以寫各種不同的測試,去檢驗你想確認的部份,
全都不會真的上傳檔案到備份主機與計算字數!

利用test double的手法,你可以避開一大堆的dependency,測試到你原本以為不可能測試的地方。

很神奇吧!


Q&A

Q1: 把物件當dependency傳入真的好帥喔,這招叫什麼?

這招只是dependency injection的一種。

為了讓code能夠被測試,這招很常用到。

Q2: function傳入檔案$file又傳入$remote跟$reader。我怎麼覺得醜到爆啊?

這樣寫確實很醜。

通常物件dependency是先在constructor傳入,再從function內拿來用:

        // $id = $remote->upload($file);
        $id = $this->remote->upload($file);

        // $words = $reader->count($file);
        $words = $this->reader->count($file);

你常常看到的這種pattern就是在幹這件事:

$stuff = new GoodClass(new ClassOne(), new ClassTwo());
    public function __constructor($dependencyOne, $dependencyTwo)
    {
        $this->dependencyOne = $dependencyOne;
        $this->dependencyTwo = $dependencyTwo;
    }

但是我懶得改寫所有相關code,而且Laravel的Eloquent建構式又很難搞定物件dependency。

學到精神就好,寫法隨機應變。

Q3: 我還滿好奇,你提到的翻譯公司網站長怎樣?

http://wordcorp.net/

(Photo via AlmaArte Photography, CC licensed. )