如何使用 TDD 為嵌入式軟件編寫更好的單元測試
什么是TDD?
測試驅(qū)動開發(fā)(TDD)是編寫軟件的迭代過程,其中單元測試是在實現(xiàn)之前開發(fā)的。這是一個緊密的反饋循環(huán),由以下步驟組成:
編寫一個單元測試,看著它失敗。
編寫足夠的代碼來通過測試。
改進(jìn)代碼(不改變其行為)。
這些步驟通常被稱為“紅色、綠色、重構(gòu)”,表示測試從失?。t色)到通過(綠色)的過程,有機(jī)會改進(jìn)代碼和測試(重構(gòu))。在開發(fā)過程中,這個循環(huán)會一遍又一遍地重復(fù)數(shù)百或數(shù)千次。
在此過程中,編寫測試是驅(qū)動 軟件開發(fā)的動力。在編寫代碼之前,您需要考慮代碼要做什么,然后將這個想法保存在單元測試中。只有這樣你才能編寫下一段代碼。這迫使你非常清楚你想要代碼做什么。
每通過測試,您就會對軟件正常運(yùn)行更有信心。而且,由于每一段代碼都是由測試驅(qū)動的,因此您終會獲得很大的測試覆蓋率——通過單元測試測試的代碼量。
不要浪費(fèi)時間編寫不可測試的代碼
單元測試的問題之一(尤其是當(dāng)您剛剛開始時)是您終可能會編寫難以測試的代碼。
例如,也許您需要訪問一些內(nèi)部狀態(tài),但您不想公開它?;蛘撸谋粶y單元可能有許多難以模擬的復(fù)雜依賴項。
編寫可測試的代碼需要經(jīng)驗,但如何才能獲得經(jīng)驗?zāi)??好吧,事實證明, 如果您從 TDD 開始,您就不需要這種經(jīng)驗。 當(dāng)您首先編寫測試時,您就無法編寫不可測試的代碼。
您將從一開始就取得成功,因此您將更有可能實際采用單元測試作為實踐。想象兩個場景:
場景 1:您編寫了一大堆代碼,然后嘗試找出如何測試它。當(dāng)您無法快速弄清楚時,您就會放棄,因為您還有軟件要交付!也許您會學(xué)到一些有關(guān)如何使您的代碼下次更易于測試的知識。
場景 2:您有一個要創(chuàng)建的軟件模塊的想法,但您不確定如何測試它。因此,您花了一些時間來弄清楚如何編寫個測試。然后你編寫一些代碼使其通過。好吧!您剛剛編寫了個單元測試。干得好,你剛剛學(xué)到了一些東西。重復(fù)此操作,直到獲得經(jīng)過全面單元測試的模塊。恭喜...您剛剛學(xué)到了很多有關(guān)單元測試的知識。
TDD是一個體驗放大器。你邊做邊學(xué)。 TDD 鼓勵您做正確的事情,以便您學(xué)得更快。你學(xué)得越多,你就越能更好地編寫單元測試。
測試驅(qū)動的心態(tài)
測試時,您會以稍微不同的方式思考您正在編寫的代碼。您不必嘗試跟蹤 您希望軟件執(zhí)行的 所有操作,而只需擔(dān)心 您希望軟件執(zhí)行的下一操作。讓我們看一個例子來說明。
我喜歡討論 TDD 的例子之一是 命令解析器,因為它被用在很多嵌入式系統(tǒng)中。通常,您希望您的系統(tǒng)能夠與外界對話,以便它實際上可以做有趣的事情。這可能只是一個用于配置的簡單串行接口,也可能是與其他設(shè)備或互聯(lián)網(wǎng)的連接。
根據(jù)我的經(jīng)驗,這些類型的接口確實可以從單元測試中受益。它們通常是定制的,并且很快就會變得復(fù)雜——有許多代碼路徑和許多需要處理的錯誤情況。而且,由于這是系統(tǒng)的外部接口,因此您不能總是期望另一端的人表現(xiàn)良好。不過,通過一些單元測試,您可以確保一切按預(yù)期工作——并且所有錯誤情況都得到處理。
考慮一個帶有簡單命令解析器的嵌入式系統(tǒng)。它從某個地方(例如串行或 USB,但我們的解析器實際上并不關(guān)心)接收字符流,并在收到特定字符序列時執(zhí)行某些操作。在這種情況下,系統(tǒng)中有一個可以控制的揚(yáng)聲器。
大多數(shù)嵌入式軟件開發(fā)人員的反應(yīng)是開始在 command_parser.c 中編寫一大堆代碼。測試驅(qū)動的方法是不同的。
步是: 編寫測試,觀察它失敗。為了編寫測試,您需要弄清楚您希望命令解析器執(zhí)行的件事。如果有協(xié)議規(guī)范(哈,對?。?,您可以看一下。如果沒有,您現(xiàn)在可以決定代碼首先要做什么。這個怎么樣?
當(dāng)收到“m”字符時,揚(yáng)聲器將靜音。
好吧,這是一個簡單、小且定義明確的功能。讓我們編寫一個單元測試,如果執(zhí)行此操作的代碼已實現(xiàn),則該測試將通過。
#include "some_test_framework.h"
#include "some_mock_framework.h"
#include "command_parser.h"
#include "mock_speaker.h"
// A test for the command_parser.
void test_WhenAnMIsReceived_ThenTheSpeakerIsMuted(void)
{
// Receive an "m."
command_parser_put_char('m');
// Make sure the mute function is called.
EXPECT_CALL(speaker_mute());
}
哇,這只是一個測試,但這里有很多設(shè)計決策。
為命令解析器定義了一個新函數(shù):command_parser_put_char()。這就是將字符輸入命令解析器的方式,以及如何傳入“m”進(jìn)行測試的方式。
揚(yáng)聲器模塊還定義了另一個新功能:speaker_mute()。這將實現(xiàn)揚(yáng)聲器的實際靜音。當(dāng)這個函數(shù)被調(diào)用時,你就知道測試已經(jīng)通過了。
由于這是一個單元測試,command_parser將被單獨(dú)測試,并且不會調(diào)用真正版本的speaker_mute()。相反,將提供一個模擬函數(shù)(可能包含在 中mock_speaker.h),并且該EXPECT_CALL宏是使用任何模擬機(jī)制的替代品。如果speaker_mute()未調(diào)用該函數(shù),測試將失敗。
請注意,這些功能實際上還不存在。但是......您剛剛定義了您想要的確切行為,并且您有一個明確的方法來測試它。如果你現(xiàn)在運(yùn)行測試,它肯定會失敗。事實上,它會編譯失敗,因為函數(shù)不存在。
現(xiàn)在進(jìn)行第二步:編寫足夠的代碼來通過測試。終于到了寫一些代碼的時候了!這是command_parser_put_char()使測試通過所需的簡單的代碼 :
// Receive a character.
void command_parser_put_char(char next_char)
{
speaker_mute();
}
請注意,您還需要為speaker_mute().詳細(xì)信息取決于您在項目中如何使用模擬。
現(xiàn)在測試應(yīng)該通過了……但請注意,我們甚至沒有檢查我們收到的是哪個字符!這可能看起來有點愚蠢,但 TDD 的目標(biāo)之一是化未完成的工作量。
現(xiàn)在這是一個簡單的例子。然而,當(dāng)代碼變得更加復(fù)雜時,任何您 實際上沒有 編寫的代碼都會使您的應(yīng)用程序更簡單、更容易理解(咳咳……更好)。當(dāng)你只做你需要做的工作時,關(guān)心日程和預(yù)算的人也會更高興。
TDD 周期的一步是重構(gòu),即 在不改變代碼行為的情況下改進(jìn)代碼。此步驟的關(guān)鍵是您已經(jīng)有了驗證行為的單元測試。因此,您可以自由地嘗試更改代碼,因為失敗的測試會立即告訴您是否更改了行為。不過,由于這只是次測試,因此還沒有太多需要改進(jìn)的地方。
命令解析器的其余部分是通過重復(fù) TDD 循環(huán)來實現(xiàn)的。那么,您希望命令解析器下一步做什么?怎么樣:
當(dāng)收到“u”字符時,揚(yáng)聲器將取消靜音。
好吧,這又是一件好事。這是一個測試:
void test_WhenAUIsReceived_ThenTheSpeakerIsUnmuted(void)
{
// When
command_parser_put_char('u');
// Then
EXPECT_CALL(speaker_unmute());
}
當(dāng)您改進(jìn)命令解析器實現(xiàn)以通過測試時,它可能看起來像這樣:
void command_parser_put_char(char next_char)
{
if (next_char == 'm')
{
speaker_mute();
}
else
{
speaker_unmute();
}
}
現(xiàn)在處理錯誤情況怎么樣?如果收到意外字符怎么辦?
當(dāng)接收到意外字符時,揚(yáng)聲器靜音狀態(tài)不變。
void test_WhenAnUnexpectedCharIsReceived_ThenTheSpeakerMuteStateIsUnchanged(void)
{
// When
command_parser_put_char('!');
// Then
DO_NOT_EXPECT_CALL(speaker_mute());
DO_NOT_EXPECT_CALL(speaker_unmute());
}
這里的代碼足以讓這個測試通過:
void command_parser_put_char(char next_char)
{
if (next_char == 'm')
{
speaker_mute();
}
else if (next_char == 'u')
{
speaker_unmute();
}
}
這里還有什么你想重構(gòu)的嗎?如果您更喜歡 switch 語句,您可以繼續(xù)更改它:
void command_parser_put_char(char next_char)
{
switch(next_char)
{
case 'm':
speaker_mute();
break;
case 'u':
speaker_unmute();
break;
default:
break;
}