Swift的世界,如何寫好單元測(cè)試?

前言

Unit Test.png

作為一名無所事事的公司蛀蟲,總是想在平靜的日子里搞出點(diǎn)事情。于是我發(fā)現(xiàn),公司的網(wǎng)絡(luò)層作為基礎(chǔ)庫竟然沒有單元測(cè)試覆蓋,是不是有失軟件工程水準(zhǔn)呢?于是就有了接下來的故事...

Why?

當(dāng)我們做某件事情的時(shí)候,我們常常抱有強(qiáng)烈的目的性,那么單元測(cè)試的目的是什么呢?為什么要有單元測(cè)試呢?

遺憾的是,作為一個(gè)‘人’,我們無法控制我們想控制的事物按照預(yù)想的情況運(yùn)作下去。即便是那些很厲害很厲害的開發(fā)人員,在介紹他的時(shí)候也只能說“幾乎沒有BUG”,而那些肉眼我們無法察覺的BUG就需要我們通過測(cè)試來發(fā)現(xiàn)并且修正它了。

What?

那么說了那么多,到底什么是單元測(cè)試呢?我們可以來看一下維基百科上的定義。

In computer programming, unit testing is a software testing method by which individual units of source code, sets of one or more computer program modules together with associated control data, usage procedures, and operating procedures, are tested to determine whether they are fit for use.
在計(jì)算機(jī)編程中,單元測(cè)試是一種軟件測(cè)試方法,通過該方法測(cè)試各個(gè)單位的源代碼,一個(gè)或多個(gè)計(jì)算機(jī)程序模塊的組合以及關(guān)聯(lián)的控制數(shù)據(jù),使用和操作程序,以確定它們是否正常運(yùn)行。

簡(jiǎn)單的來說,單元測(cè)試是使用程序控制的以類或者函數(shù)為單元的期望判斷。比如,我們需要測(cè)試一個(gè)計(jì)算器中的加法(來自于Apple官方文檔):

- (void)testAddition
{
   // obtain the app variables for test access
   app                  = [NSApplication sharedApplication];
   calcViewController   = (CalcViewController*)[[NSApplication sharedApplication] delegate];
   calcView             = calcViewController.view;
 
   // perform two addition tests
   [calcViewController press:[calcView viewWithTag: 6]];  // 6
   [calcViewController press:[calcView viewWithTag:13]];  // +
   [calcViewController press:[calcView viewWithTag: 2]];  // 2
   [calcViewController press:[calcView viewWithTag:12]];  // =
    XCTAssertEqualObjects([calcViewController.displayField stringValue], @"8", @"Part 1 failed.");

}

在這個(gè)中,我們的測(cè)試目的只有一個(gè),那就是在加法的情況之下,進(jìn)行6+2的運(yùn)行,并且期望結(jié)果為8,如果期望不滿足,那么Xcode就會(huì)在該斷言上失敗。這幾乎是最簡(jiǎn)單的一個(gè)單元測(cè)試了,但是在真實(shí)的世界中,我們所碰到的情況比這復(fù)雜的多。比如,我們需要測(cè)試的方法是異步的,我們所測(cè)試的方法互相依賴,我們需要測(cè)試一個(gè)方法的性能等等,那么如何在真實(shí)的復(fù)雜情況之下編寫出令人滿意的良好測(cè)試呢?

命名

按照Apple官方文檔,相信你能很快的新建一個(gè)項(xiàng)目的測(cè)試Target,當(dāng)你新建一個(gè).swift文件之后你的心中可能會(huì)突然一下顫抖,然后發(fā)出宇宙終極的三問:我在哪?我是誰?我在干什么?

我真的好懵逼.png

是的,你在公司,你是一個(gè)死宅碼農(nóng),你在寫單元測(cè)試!

可是,你卻遲遲不能動(dòng)手寫下一行代碼,不是因?yàn)槟悴恢老胍獪y(cè)試什么功能,你知道你想測(cè)試網(wǎng)絡(luò)層的Get請(qǐng)求是否正常運(yùn)作,但是你不知道該怎么樣給這個(gè)測(cè)試取一個(gè)名字,就好像一個(gè)爸爸看到剛出生的baby一樣手足無措。

要不就叫它func testGet()吧!

然而當(dāng)你敲下方法名的定義之后,你敏銳的工程師思維開始發(fā)揮了作用,如果我的Get方法帶參數(shù)怎么辦?如果不帶參正常運(yùn)行,帶參失敗了怎么辦?也就是說我不止需要一個(gè)Get的測(cè)試方法,那么我的命名應(yīng)該如何呢?

確實(shí),這樣的測(cè)試不僅沒有能夠測(cè)試到應(yīng)該覆蓋的測(cè)試case,同時(shí)也不便于維護(hù),他人很難通過方法命名一眼就看出你測(cè)試的意圖。

那么良好的測(cè)試命名應(yīng)該是怎么樣的呢?

總的來說,良好的測(cè)試命名應(yīng)該有如下的特點(diǎn):

  • 全局測(cè)試內(nèi)的命名統(tǒng)一。
  • 命名可以清晰的闡明測(cè)試意圖。
  • 命名可以清晰的闡明測(cè)試期望以及副作用(如果有的話)。

1.Plan A

在A方案中,我們單元測(cè)試的名稱將分為三部分:方法名稱(method name)+ 執(zhí)行測(cè)試用例的狀態(tài)(state under test)+ 預(yù)期名為(expected behavior)示例如下:

/// 這是一個(gè)除法的測(cè)試,在分母為0的情況之下,我們期望拋出異常
func divide_ZeroAs2ndParam_ExceptionThrown()

可以看到,在這樣的命名規(guī)范之下,他人也可以通過方法名清晰明了的知道該方法在怎樣的期望輸入或者狀態(tài)之下會(huì)產(chǎn)出什么樣的輸出或者狀態(tài)。

更加詳細(xì)的關(guān)于該測(cè)試方法名的論述,大家可以看一下Roy Osherove](http://osherove.com/blog/2005/4/3/naming-standards-for-unit-tests.html)的Blog。

Tips:當(dāng)我們修改了所測(cè)試的方法名字之后,原測(cè)試方法就已經(jīng)偏離了命名規(guī)范,所以需要我們手動(dòng)的修改測(cè)試方法。但是這樣的工作明顯是最無效和重復(fù)的。因此也可以這樣做:我們將原來的方法名稱(method name)更改成了抽象的方法名稱,而不是將原來的方法名稱一字不落的當(dāng)做測(cè)試方法的前綴。

2. Plan B

在B方案中,我們將采用Given-When-Then的方式進(jìn)行命名組織,該組織方式來源于BDD(Behavior-Driven Development)。具體的命名例子如下:

/// Given: 當(dāng)前測(cè)試所給予的輸入或者初始狀態(tài)
/// Action: 當(dāng)前測(cè)試所要進(jìn)行的操作
/// Then: 當(dāng)前測(cè)試所期望的輸出狀態(tài)或者輸出
func Given_StateUnderTest_When_ActionUnderTest_Then_ExpectedOutcomes()

我們可以看到,在Given-When-Then的命名方式之下,我們滿足了所有良好測(cè)試命名的特點(diǎn),與此同時(shí)似乎還看起來有一些過于“啰嗦”,但是這也并不是什么大問題,畢竟清晰的意圖的優(yōu)先級(jí)總比簡(jiǎn)短的命名優(yōu)先級(jí)更高。

總的來說,測(cè)試的命名并沒有刻板的規(guī)定,只要滿足自身的測(cè)試需要,滿足公認(rèn)的測(cè)試名稱規(guī)范就可以。當(dāng)然還有一些其他的命名方式,但是基本上也都是與上述的兩種方法類似或者是變種。最重要的是,我們知道了命名的準(zhǔn)則,那么我們也可以制作出屬于自己的規(guī)范。

關(guān)于斷言數(shù)的爭(zhēng)論

在我跟同事關(guān)于單元測(cè)試的討論中,同事提出單元測(cè)試最好只有一個(gè)assert,不然當(dāng)測(cè)試不通過的時(shí)候無法知道具體fail在哪里。但是,具體在iOS的XCTest中,我們知道當(dāng)某一個(gè)斷言無法滿足條件的時(shí)候,Xcode會(huì)直接卡在那個(gè)斷言之上,并且告訴你不通過的原因,如下圖所示:

斷言不滿足.png

但是我也知道,一個(gè)單元測(cè)試的用例最好只包含一個(gè)assert這樣的觀點(diǎn)也由來已久,那么到底在編寫單元測(cè)試的用例的時(shí)候該不該使用多個(gè)斷言呢?

我們先來看看贊成單元測(cè)試用例只寫一個(gè)斷言的其他理由:

如果你在一個(gè)測(cè)試中包含了不只一個(gè)斷言,則你的測(cè)試目的就不只一個(gè)。在這種情況下,測(cè)試名稱變得奇怪不清晰,測(cè)試變得太長,反饋也變得不清晰;你永遠(yuǎn)無法知道哪個(gè)斷言通過了,哪個(gè)斷言失敗了。假如你依次有三個(gè)斷言。如果第一個(gè)斷言失敗了,則后面兩個(gè)永遠(yuǎn)都不會(huì)檢查。如果你修改了一些生產(chǎn)代碼,那么當(dāng)代碼變化時(shí),后面兩個(gè)斷言就無法發(fā)揮作用了。在這種情況下,你就會(huì)錯(cuò)誤地認(rèn)為自己的代碼有安全保障和回歸測(cè)試。 ---編寫良好的單元測(cè)試

其實(shí),我確實(shí)同意上述的某些觀點(diǎn)的,比如測(cè)試的目的應(yīng)當(dāng)只有一個(gè),
但是當(dāng)你只有一個(gè)測(cè)試目的的時(shí)候就代表我們只能有一個(gè)斷言么?我想這個(gè)推論應(yīng)當(dāng)是錯(cuò)誤的。

我們可以在StackOverFlow里看到相關(guān)的討論,其中第二個(gè)回答我深以為然,比如我們要測(cè)試所得到數(shù)值是否在一個(gè)數(shù)值區(qū)間內(nèi),我們的單元測(cè)試代碼可能是這樣的:

public void ValueIsInRange()
{
  int value = GetValueToTest();

  Assert.That(value, Is.GreaterThan(10), "value is too small");
  Assert.That(value, Is.LessThan(100), "value is too large");
} 

在這里我們所要測(cè)試的確確實(shí)實(shí)是一個(gè)單獨(dú)的目的,即“該數(shù)值是否在某個(gè)區(qū)間內(nèi)”,但是很顯然我們需要兩個(gè)斷言來分別判斷數(shù)值的上界和下界。當(dāng)然我們也可以通過isInRange之類的方便來將兩個(gè)斷言合并成一個(gè),但是這樣真的是一個(gè)好的測(cè)試用例么?當(dāng)用例的失敗的時(shí)候,我們只能知道該數(shù)值不在指定的范圍內(nèi),但是我們甚至都不知道它是超過了上界還是下界。

綜上所述,“一個(gè)單元測(cè)試最好只有一個(gè)斷言”并不十分準(zhǔn)確,或許我們應(yīng)當(dāng)信奉的應(yīng)該是“一個(gè)單元測(cè)試應(yīng)當(dāng)只有一個(gè)邏輯單元,只有一個(gè)測(cè)試目的”,本著這樣的宗旨,寫出只有一個(gè)斷言的測(cè)試應(yīng)該是自然而然的事情,在需要的時(shí)候可以使用多個(gè)斷言。

函數(shù)式編程和單元測(cè)試

在傳統(tǒng)的面向?qū)ο缶幊踢^程中,我們總是能會(huì)和各種各樣的狀態(tài)機(jī)進(jìn)行交互,因?yàn)槊嫦驅(qū)ο缶幊痰暮诵氖欠庋b,那么我們就免不了將各種狀態(tài)封裝在對(duì)象的內(nèi)部。然而隨著軟件規(guī)模的不斷龐大,各種復(fù)雜的狀態(tài)機(jī)也導(dǎo)致了難以維護(hù)、難以迭代和難以測(cè)試的問題。

那么具體在單元測(cè)試當(dāng)中,狀態(tài)機(jī)又是怎樣拖累我們的測(cè)試的呢?又為什么說純函數(shù)的方法便于單元測(cè)試呢?

首先我們需要搞懂什么是副作用:

In computer science, a function or expression is said to have a side effect if it modifies some state outside its scope or has an observable interaction with its calling functions or the outside world besides returning a value.
在計(jì)算機(jī)科學(xué)中,如果一個(gè)函數(shù)或表達(dá)式修改某個(gè)超出其范圍的狀態(tài),或者除了返回一個(gè)值之外還有一個(gè)與其調(diào)用函數(shù)或外部世界的可觀察的交互,這個(gè)函數(shù)或表達(dá)式會(huì)產(chǎn)生副作用。 -------------- from wikipedia

反過來說,無副作用的函數(shù)是指不會(huì)對(duì)外部作用域產(chǎn)生影響并且函數(shù)的作用是恒定不變的。

對(duì)于單元測(cè)試而言,很明顯無副作用的函數(shù)更加容易測(cè)試,函數(shù)式編程的每個(gè)單元函數(shù)更加符合“單一職責(zé)”,而“單一職責(zé)”的函數(shù)則契合了單元測(cè)試?yán)?測(cè)試的目的應(yīng)當(dāng)只有一個(gè)"的準(zhǔn)則。

舉個(gè)例子,如下有一個(gè)非純函數(shù)的場(chǎng)景(impure function):

class Person {  
    var friends: [String] = []
    func addFriend(_ name: String) {
        self.friends.append(name)
    }
}

class PersonTest: XCTestCase {
    let me = Person()
    func testAddFriend() {
        me.addFriend("jason")
        XCTAssert(me.friends == ["jason"])
    }
}

我們可以看到,上述代碼段的寫法是經(jīng)典的面向?qū)ο笏枷胂碌膶懛?。我們?cè)跍y(cè)試的過程中創(chuàng)建了一個(gè)Person的實(shí)例對(duì)象me,然后在testAddFriend方法中測(cè)試添加朋友的這一個(gè)操作是否正確執(zhí)行。然而這樣簡(jiǎn)單的操作卻存在著很大的“副作用”,首先,在執(zhí)行操作的時(shí)候我們并不知道之前是否已經(jīng)存在friends,如果存在了之前已經(jīng)存在過friends,那么這里的斷言將會(huì)失敗,其次在addFriends所產(chǎn)生的副作用也會(huì)影響之后的單元測(cè)試,可能會(huì)導(dǎo)致之前好好的單元測(cè)試用例發(fā)生不可預(yù)計(jì)的錯(cuò)誤。

那么,經(jīng)過無副作用的函數(shù)應(yīng)該是怎么樣的呢?在這里推薦一下onevcat關(guān)于單向數(shù)據(jù)流控制器的文章,在那里會(huì)有更加清晰易懂的純函數(shù)式的例子。在本篇文章中,主要為了更加簡(jiǎn)單的展示“純函數(shù)”對(duì)測(cè)試的作用,因此也是一些比較簡(jiǎn)單的改造,大概如下所示:

class Person {
    var state: State = State(friends: [])

    struct State {
        let friends: [String]
        /// other state stuff ...
    }

    enum Action {
        case addFriend(String)
        /// other action stuff ...
    }

    lazy var reducer: (State, Action) -> State = { (state: State, action: Action) in
        var internalState = state
        switch action {
        case .addFriend(let name):
            internalState = State(friends: state.friends + [name])
        }
        return internalState
    }

    func dispatch(_ action: Action) {
        let previousState = state
        let nextState = reducer(state, action)
        state = nextState
    }
}

class PersonTest: XCTestCase {

    let me = Person()

    func testAddFriend() {
        let initState = Person.State(friends: [])
        let newState = me.reducer(initState, .addFriend("jason"))
        /// 在這里的測(cè)試沒有對(duì)外部變量產(chǎn)生任何副作用
        XCTAssert(initState.friends == ["jason"])
    }

    func testOtherMethod() {
        /// 其余的測(cè)試可以安全的進(jìn)行,me不會(huì)受到不安全的變動(dòng)
    }
}

我們可以看到,經(jīng)過簡(jiǎn)單的函數(shù)式改造之后,測(cè)試函數(shù)就可以異常的純粹,測(cè)試用例也將清晰明了。所以,當(dāng)你發(fā)先自己的單元測(cè)試無法進(jìn)行下去,各種corner case越來越多,各種狀態(tài)紛繁雜亂的時(shí)候,或許是時(shí)候考慮一下減少副作用,使用函數(shù)式的方法來改造我們的生產(chǎn)代碼,將自己解放出來。

雖然純函數(shù)式的編程有這樣那樣的好處,但是遺憾的是,在實(shí)際的編程開發(fā)中,我們總是不可避免的產(chǎn)生副作用。諸如:修改全局變量,修改靜態(tài)變量,修改inout入?yún)?,拋出異常,I/O操作,調(diào)用其他的具有副作用的函數(shù)等等。那么我們需要做的是,將不可避免的副作用限制在可控的范圍之內(nèi),如果在程序中,所有的函數(shù)都在任意的作用域內(nèi)隨意穿梭,那么代碼將陷入維護(hù)和迭代的黑洞,永世不得翻身。

Stubs and Mock

注:在關(guān)于單元測(cè)試的文章中我們常常可以聽到StubMock的概念,而對(duì)于剛剛開展單元測(cè)試的人來說常常會(huì)混淆兩者。簡(jiǎn)單來說,Stub指的是當(dāng)我們需要依賴某些真實(shí)的數(shù)據(jù)接口的時(shí)候,我們通過提供偽造的數(shù)據(jù)來進(jìn)行測(cè)試。而Mock則是在Stub的基礎(chǔ)上增加了對(duì)所需要依賴接口的校驗(yàn),保證該方法被調(diào)用。

假設(shè)我們需要測(cè)試一個(gè)網(wǎng)絡(luò)層,誠然,我們也可以使用https://httpbin.org/的開放接口進(jìn)行測(cè)試,但是這樣的測(cè)試有一些問題:

  • 測(cè)試返回時(shí)間的不確定性,不能夠快速測(cè)試
  • 測(cè)試依賴外部環(huán)境,測(cè)試數(shù)據(jù)不穩(wěn)定
  • 難以模擬一些corener case和錯(cuò)誤返回,難以提升測(cè)試覆蓋率

基于以上幾點(diǎn)原因,一個(gè)比較好的辦法就是Mock數(shù)據(jù)。在OC的時(shí)代,由于OC是動(dòng)態(tài)的語言,所以我們有一個(gè)非常強(qiáng)大的庫--OC,我們可以依賴runtime輕松的fake出想要的數(shù)據(jù)來進(jìn)行單元測(cè)試。

當(dāng)然,來到了Swift時(shí)代之后,runtime的方法就行不通了,但是我們依舊可以使用自定義的URLProtocol來實(shí)現(xiàn)Mock,比較不錯(cuò)的開源項(xiàng)目比如Mockingjay,使用它我們就可以非常簡(jiǎn)單的完成網(wǎng)絡(luò)層的Mock。

Quick Check

如果你學(xué)過Haskell,那么你大概率聽說過Quick Check。在上一小節(jié)中,我們知道在某些時(shí)候我們需要通過Mock的技術(shù)來偽造數(shù)據(jù),但是我們難道就止步于此了么?

One more thing...

例如,當(dāng)我們需要測(cè)試一個(gè)除法的時(shí)候,我們編寫了如下的代碼:

func testDivision() {
    XCTAssert(1.divide(a: 1) == 1)
}

嗯,這樣的測(cè)試用例很簡(jiǎn)單,我們輸入了[(1,1)]作為測(cè)試的輸入集,當(dāng)進(jìn)行單元測(cè)試的時(shí)候,我們總是能得到成功的測(cè)試結(jié)果,但是很明顯,當(dāng)分母為0這樣的重要的數(shù)據(jù)邊界條件的時(shí)候程序就會(huì)出現(xiàn)錯(cuò)誤。當(dāng)然,上述的例子還只是一個(gè)極其純粹的單元測(cè)試,在真實(shí)的軟件環(huán)境當(dāng)中,我們將遇到的問題將更加復(fù)雜。

在無窮的測(cè)試集中找到最小的最高效的測(cè)試集幾乎是單元測(cè)試最難的部分

有限的人力人腦和無限的測(cè)試集將是永恒的矛盾,所以人們便想出了類似Quick Check的這樣的隨機(jī)數(shù)據(jù)生成器。主要的思想就是,通過你給定的數(shù)據(jù)范圍和類型限定,程序自動(dòng)為你生成相關(guān)的數(shù)據(jù)來進(jìn)行測(cè)試,當(dāng)然啦萬能的Github已經(jīng)有人實(shí)現(xiàn)過了--typelift/SwiftCheck。

具體的相關(guān)使用并不想浪費(fèi)篇幅來講,其實(shí)更讓我在意的是它的局限。

確實(shí),我們現(xiàn)在可以根據(jù)給定的類型或者是范圍來隨機(jī)生成測(cè)試集,我們可以依靠機(jī)器的蠻力來進(jìn)行這樣暴力的測(cè)試,但是它真的帶給了我們有效的測(cè)試么?它真的帶給了我們高效的測(cè)試么?

不可替代的人力

Quick Check確實(shí)給了我們解決問題的一個(gè)新的視野,但是它也有始終無法突破的局限。例如上述的例子,我們確實(shí)可以通過Quick Check隨機(jī)快速的生成測(cè)試數(shù)據(jù)集,但是“隨機(jī)”與其說是它的優(yōu)點(diǎn),不如說是它的劣勢(shì)。

“隨機(jī)”是機(jī)器無奈的選擇,是程序的妥協(xié)。即便你的機(jī)器再快,在無限的測(cè)試集面前依然無限趨近于0,機(jī)器無法思考到分母不能為0,類似這樣的策略和思考過程正是人腦所擅長的。

我們總覺得依靠“點(diǎn)點(diǎn)點(diǎn)”的測(cè)試人員很“Low”,甚至于我們總是希望這樣的測(cè)試人員被淘汰出局。但是我們要知道,“點(diǎn)點(diǎn)點(diǎn)”測(cè)試人員的價(jià)值并不在于靈活的手指,而在于靈活的思考策略。經(jīng)驗(yàn)豐富的測(cè)試人員,總能在無限的測(cè)試集中找到最有效高效的子集,從而保證絕大多數(shù)的情況之下的軟件質(zhì)量。

當(dāng)然,如果AI可以解決這樣的策略問題當(dāng)然最好,但目前來看“人工智能”還是蠢得可怕。

結(jié)語

“測(cè)試是為發(fā)現(xiàn)錯(cuò)誤而執(zhí)行程序的過程”。 ---- 《單元測(cè)試的藝術(shù)》

在資本洪流之下,中國的互聯(lián)網(wǎng)公司普遍生活在恐慌之下,唯恐被市場(chǎng)淘他們加班加點(diǎn),或小步或大步的跑著。面對(duì)這產(chǎn)品飄忽不定的需求,技術(shù)人是否還能保持一顆匠人之心。

十幾年前,我們?cè)谟旰蟮哪嗟乩锿嬷喟?,樂此不疲。母親氣呼呼的過來把我拖走,“還在玩?還不去做作業(yè),玩這個(gè)有什么用!”。我戀戀不舍的看著我用泥巴建起的王國。

“這很有用”。

感謝參考

編寫良好的單元測(cè)試
iOS Unit Testing and UI Testing Tutorial
Real World Mocking in Swift

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 本文試圖總結(jié)編寫單元測(cè)試的流程,以及自己在寫單元測(cè)試時(shí)踩到的一些坑。如有遺漏,純屬必然,歡迎補(bǔ)充。 目錄概覽: 編...
    蘇尚君閱讀 3,569評(píng)論 0 4
  • Android單元測(cè)試介紹 處于高速迭代開發(fā)中的Android項(xiàng)目往往需要除黑盒測(cè)試外更加可靠的質(zhì)量保障,這正是單...
    東經(jīng)315度閱讀 3,424評(píng)論 6 37
  • 單元測(cè)試不是一個(gè)小工程,需要多用些時(shí)間才能做好,不要希望通過這個(gè)文章就能掌握單元測(cè)試,這只是一個(gè)入門,需要自己動(dòng)手...
    勇不言棄92閱讀 8,108評(píng)論 9 60
  • 摘自http://www.51testing.com/html/75/n-3721875.html 單元測(cè)試,一個(gè)...
    許小小晴閱讀 513評(píng)論 0 1
  • 有的人,勤勞樸實(shí),買菜時(shí)從不跟你斤斤計(jì)較。 有的人,虛榮懶惰,買菜時(shí)總是跟你斤斤計(jì)較。
    酒和詩和遠(yuǎn)方閱讀 264評(píng)論 0 0

友情鏈接更多精彩內(nèi)容