Unit test là việc mà lập trình viên nào cũng thấy cần phải làm, nhưng nó có vẻ khó hiểu, chán hoặc mất thời gian để thực hiện.

Khi lập trình ra chức năng mới thì ai cũng phấn khích. Nhưng liệu có mấy người sẵn sàng dành ra một nửa thời gian để viết mã kiểm thử lại những chức năng mình đã viết

Trong ví dụ này bạn sẽ học kiểm lập trình kiểm thử để sau đó tự động hóa công việc kiểm tra chất lượng ứng dụng.

Nhập môn

Ví dụ này sử dụng Swift 3 và Xcode 8. Bạn có thể tải project và làm theo các bước dưới đây.

Đầu tiên bạn vào Menu > Product > Test (command + U).

Khi bạn chạy các test, Xcode sẽ biên dịch và bạn sẽ thấy có cửa sổ nó bật lên rồi tắt đi luôn sau đó nó sẽ thông báo là thành công. Ở cột trái (left pane) chọn Test navigator.

Nó cho bạn thấy có 3 tests đã được thêm, với mỗi một test nó có một dấu tích mày xanh, cho thấy cái test đó đã thành công. Để xem các test được thực thi của mỗi thư mục các bạn có thể ấn vào thư mục đấy để nó xổ xuống ví dụ như High RollerTests nó có chữ T ở ngay đầu.

Có vào điều quan trọng bạn cần chú ý:

-XCTest là một framework cung cấp bới Xcode, @testable import High_Roller được import vào để cho phép kiểm thử các doạn mã trong High_Roller module. 

-setup() và teardown(): Chúng được gọi trước và ngay sau mỗi test.

-testExample() và testPerformanceExample(): là các phương thức chữa mã để test. Mọi test func sẽ phải bắt đầu với từ “test” để Xcode có thể nhận biết được đó là func dung để test.

Unit Test là gì?

Một unit test là hàm được viết để kiểm thử một đoạn code của bạn. Nó chạy độc lập, vào không được biên dịch vào trong file chạy cuối cùng của ứng dụng. Unit Test chỉ dùng trong khi đang phát triển phần mềm để đảm bảo nếu lập trình viết thay đổi, thêm mới code, thì các chức năng khác vẫn hoạt động theo yêu cầu.

Thực tế có một số dự án mã kiểm thử còn nhiều hơn mã chức năng. Có vẻ như Unit Test lãng phí thêm thời gian. Nhưng Unit Test giúp phát hiện ra  lỗi tiềm ẩn trong ứng dụng. Ứng dụng mong manh, dễ đổ vỡ nếu không có unit test, vì một chức năng thay đổi sẽ ảnh hưởng đến nhiều chức năng, thành phần khác, "Rút dây - động rừng".

Test Driven Development

TDD là một nhánh của unit test nơi mà bạn bắt đầu kiểm thử. Nó trông có thể giống như một cách rất lạ để bắt đầu và bạn có thể thấy vài đoạn mã nhìn khá dị dưới đây.

TDD có 3 bước được lặp lại như sau:

1.Red: Viết một test sai.

2. Green: Test không có lỗi

3. Refactor: Bất khì lúc nào ứng dụng hoặc test cần được refactor để nó tốt hơn thì bạn nên chọn option này.

Bắt đầu viết mã

Trong Xcode ở danh sách bên trái như ở trên mình đã nói chọn High RollerTest chuột phải chọn File\New\File… và chọn macOS\Unit Test Case Class. Chọn Next và điền tên là DiceTests, nhớ chọn ngôn ngữ là Swift -> Next -> Create.

Chọn tất cả các mã bên trong class và xoá, thêm các đoạn mã phí dưới:

@testable import High_Roller

Bây giờ bạn có thể xoá HighRollerTests.swift  cũng được.

Trong lớp DiceTest, them func dưới đây:

func testForDice() {
    let _ = Dice()
  }

Nó sẽ biên dịch lỗi trước khi bạn chạy và báo lỗi sau "Use of unresolved identifier 'Dice'". Trong TDD(có 3 bước) hiện tại thì đang bị sai ngay ở bước 1.

Để có thể chạy được test này bạn chọn Model group trong High Roller, -> File\New\File… để tạo một swift file và đặt tên là Dice.swift.

Thêm đoạn mã sau:

struct Dice {
 
}

Quay lại với DiceTests.swift, lỗi sẽ vẫn hiện cho đến khi bạn build lại. Tuy nhiên bạn có thể chạy bằng cách khác.

Nếu bạn chọn hình kim cương mả bên trái như hình dưới đây, thì nó sẽ chỉ chạy cái test được chọn. Thử ấn luôn đi, và cái hình kim cương đấy sẽ đổi thành màu xanh nếu chạy thành công và đỏ nếu sai.

Bạn có thể ấn Command-U để chạy toàn bộ các tests. Nếu khi bạn chạy hết các tests mà nó lâu quá thì bạn có thể muốn nó nhanh nhất có thể, thì bạn có thể không cho toàn bộ các test chạy tự động. Các bạn có thể vào product->Edit scheme/Test.

Kiểm tra rỗng

Các bạn thêm đoạn mã dưới đây:

// 1
  func testValueForNewDiceIsNil() {
    let testDie = Dice()
 
    // 2
    XCTAssertNil(testDie.value, "Die value should be nil after init")
  }

1)Tên của func bắt đầu với “test”, và phần còn lại của tên func giải thích test đó để làm gì.

2)Với test sử dụng một trong nhiều XCTAssert func để chắc chắn giá trị là nil, tham số thứ hai của ACTAssertNil() là một optinal string nó cung cấp thông báo lỗi nếu test thất bại.

Khi bạn chay test này thì nó sẽ báo lỗi "Value of type 'Dice' has no member 'value'".

Để sử lỗi này bạn thông thuộc tính value vào Dice.swift:

 var value: Int?

Mỗi một đối tượng xúc xắc thì có thể lăn(“roll”)  và có giá trị sau khi lăn. Thêm test sau cho file DiceTests.swift:

func testRollDie() {
    var testDie = Dice()
    testDie.rollDie()
 
    XCTAssertNotNil(testDie.value)
  }

Test này sử dụng XCTAssertNotNil() thay thế cho XCTAssertNil() của test trước.

Trong struct Dice chúng ta vẫn chưa khai báo phương thức rollDie() nên trình biên dịch sẽ báo lỗi, để sửa thì các bạn them phương thức rollDie() vào Dice.swift:

func rollDie() {
 
  }

Chạy test này bạn sẽ thấy một cảnh báo về việc sử dụng var vì chúng ta không thay đổi giá trị thì để loại bỏ cảnh báo này các bạn làm như sau:

mutating func rollDie() {
    value = 0
  }

Với 2 ví dụ nhỏ trên các bạn có thể hiểu sơ lược cách mà TDD kiểm tra các đoạn mã.

Lập trinh để kiểm thử được

Tiếp theo chúng ta sẽ viết một số đoạn mã, ví dụ một con xúc xắc có 6 mặt nên là giá trị của nó sẽ từ 1 đến 6. Để làm điều này các bạn thêm đoạn mã sau:

func testDiceRoll_ShouldBeFromOneToSix() {
    var testDie = Dice()
    testDie.rollDie()
 
    XCTAssertTrue(testDie.value! >= 1)
    XCTAssertTrue(testDie.value! <= 6)
    XCTAssertFalse(testDie.value == 0)
  }

Chạy test này thì sẽ thất bại vì value hiện tại của Dice chưa có các bạn có thể thay đổi thành 1 thì nó sẽ đúng.

Thay vì test từng giá trị một, thì chúng ta sẽ tạo một phương thức để có thể thực hiện test nhiều lần.

Các bạn thêm đoạn mã sau:

func testRollsAreSpreadRoughlyEvenly() {
    var testDie = Dice()
    var rolls: [Int: Double] = [:]
 
    // 1
    let rollCounter = 600.0
 
    for _ in 0 ..< Int(rollCounter) {
      testDie.rollDie()
      guard let newRoll = testDie.value else {
        // 2
        XCTFail()
        return
      }
 
      // 3
      if let existingCount = rolls[newRoll] {
        rolls[newRoll] = existingCount + 1
      } else {
        rolls[newRoll] = 1
      }
    }
 
    // 4
    XCTAssertEqual(rolls.keys.count, 6)
 
    // 5
    for (key, roll) in rolls {
      XCTAssertEqualWithAccuracy(roll,
                                 rollCounter / 6,
                                 accuracy: rollCounter / 6 * 0.3,
                                 "Dice gave \(roll) x \(key)")
    }
  }

Test này làm những việc sau:

1)rollCounter cho biết xúc xắc sẽ lăn bao nhiêu vòng.

2)Nếu biến testDie không có giá trị thì sẽ trả về XCTFail() vì sử dụng từ khoá guard.

3)Sau mỗi lần lăn, bạn thêm kết quả vào một dictionary.

4)XCTAssertEqual là lệnh để chắc chắn chỉ có 6 keys trong dictionary.

5)XCTAssertEqualWithAccuracy() cho phép so sánh giữa các số với khoảng không chính xác. Khi XCTAssertEqualWithAccuracy() được gọi nếu gặp lỗi thì nó sẽ đưa ra thông báo lỗi.

Chạy test này thì bạn sẽ thấy lỗi. Để xem chi tiết hơn về lỗi, bạn vào Issue Navigator để đọc xem có điều gì xảy ra.

Cuối cùng chúng ta sẽ random value ở func rollDie():

mutating func rollDie() {
    value = Int(arc4random_uniform(UInt32(6))) + 1
  }

Bây giờ bạn có thể ấn Comand0-U để kiểm thử tất cả các tests.

Vấn đề bây giờ bạn muốn mở rộng ứng dụng này khi mà xúc xắc có thể lăn 4, 8, 12, 20, 100 mặt

Chỉnh lại các đoạn mã

func testRollingTwentySidedDice() {
    var testDie = Dice()
    testDie.rollDie(numberOfSides: 20)
 
    XCTAssertNotNil(testDie.value)
    XCTAssertTrue(testDie.value! >= 1)
    XCTAssertTrue(testDie.value! <= 20)
  }

Thì để cái tiến các bạn thêm tham số vào func rollDie, tham số này có chức năng xác định số mặt của xúc xắc:

mutating func rollDie(numberOfSides: Int) {

Nhưng nó sẽ làm các test của chúng ta bị sai bởi vì chúng ta đang test với 6 mặt.Thì đơn giản bây giờ chúng ta sẽ cho nó giá trị mặc định bằng cách:

  mutating func rollDie(numberOfSides: Int = 6) {

Nhưng bây giờ các tests của chúng ta vẫn chưa thể kiểm tra với xúc xắc 20 mặt.Vậy chúng ta có thể thêm func sau:

func testTwentySidedRollsAreSpreadRoughlyEvenly() {
    var testDie = Dice()
    var rolls: [Int: Double] = [:]
    let rollCounter = 2000.0
 
    for _ in 0 ..< Int(rollCounter) {
      testDie.rollDie(numberOfSides: 20)
      guard let newRoll = testDie.value else {
        XCTFail()
        return
      }
 
      if let existingCount = rolls[newRoll] {
        rolls[newRoll] = existingCount + 1
      } else {
        rolls[newRoll] = 1
      }
    }
 
    XCTAssertEqual(rolls.keys.count, 20)
 
    for (key, roll) in rolls {
      XCTAssertEqualWithAccuracy(roll,
                                 rollCounter / 20,
                                 accuracy: rollCounter / 20 * 0.3,
                                 "Dice gave \(roll) x \(key)")
    }
  }

Các bạn có thể thấy khi chạy thì sẽ xuất hiện 7 lỗi: số các keys chỉ cho phép 6.

Đơn giản bây giờ chúng ta chỉ cần thêm tham số chính là số các mặt của xúc xắc rồi chạy là được thôi.

Chỉnh lại mã kiểm thử

Bạn có thể thấy testRollsAreSpreadRoughlyEvenly() và testTwentySidedRollsAreSpreadRoughlyEvenly() có các đoạn mã giống nhau, vậy bạn có thể tách nó thành một private function như sau.

extension DiceTests {
 
  fileprivate func performMultipleRollTests(numberOfSides: Int = 6) {
    var testDie = Dice()
    var rolls: [Int: Double] = [:]
    let rollCounter = Double(numberOfSides) * 100.0
    let expectedResult = rollCounter / Double(numberOfSides)
    let allowedAccuracy = rollCounter / Double(numberOfSides) * 0.3
 
    for _ in 0 ..< Int(rollCounter) {
      testDie.rollDie(numberOfSides: numberOfSides)
      guard let newRoll = testDie.value else {
        XCTFail()
        return
      }
 
      if let existingCount = rolls[newRoll] {
        rolls[newRoll] = existingCount + 1
      } else {
        rolls[newRoll] = 1
      }
    }
 
    XCTAssertEqual(rolls.keys.count, numberOfSides)
 
    for (key, roll) in rolls {
      XCTAssertEqualWithAccuracy(roll,
                                 expectedResult,
                                 accuracy: allowedAccuracy,
                                 "Dice gave \(roll) x \(key)")
    }
  }
 
}

Các bạn có thể thấy tên của func này nó không bắt đầu bởi “test” thì nó có nghĩa là nó sẽ không bao giờ chạy như cách chúng ta chạy với mỗi test.

Quay trở lại với lớp DiceTests và sửa lại func testRollsAreSpreadRoughlyEvenly() và testTwentySideRollsAreSpreadRoughlyEvenly() và chạy lại các tests:

func testRollsAreSpreadRoughlyEvenly() {
    performMultipleRollTests()
  }
 
  func testTwentySidedRollsAreSpreadRoughlyEvenly() {
    performMultipleRollTests(numberOfSides: 20)
  }

Sử dụng #line

Khi định nghĩa một func, bạn có thể khai báo một tham số với giá trị mặc định là #line, nó chứa được cái dòng mà gọi đến func đấy. Chúng ta có thể sử dụng XCTAssert để gửi một thông báo lỗi tới dòng đó.

Trong DiceTests extension, thay đổi func performMultipleRollTests(numberOfSides:) như sau:

fileprivate func performMultipleRollTests(numberOfSides: Int = 6, line: UInt = #line) {

Và thay đổi XCTAsserts giống như sau:

XCTAssertEqual(rolls.keys.count, numberOfSides, line: line)
 
for (key, roll) in rolls {
  XCTAssertEqualWithAccuracy(roll,
                             expectedResult,
                             accuracy: allowedAccuracy,
                             "Dice gave \(roll) x \(key)",
                             line: line)
}

Bây giờ bạn hãy chạy nếu bạn thấy lỗi ở dòng performMultipleRollTests(numberOfsides:line:) thì bạn thay đổi rollDie(numberOfSides:)  bằng cách thêm numberOfSides vào trong arc4random_uniform(). Và chạy lại tất cả các tests.

Thêm Unit Test vào ứng dụng đã có

TDD có thể hữu dụng khi viết code mới, nhưng thường thì bạn sẽ bổ sung các test sau khi code.Với app này thì chúng ta sẽ thêm một struct mới có tên Roll, nó sẽ chưa một mảng các Dice và thuộc tính numberOfSides. Nó xử lý việc lăn của tất cả các dice. Các bạn thêm các đoạn mã sau:

struct Roll {
 
  var dice: [Dice] = []
  var numberOfSides = 6
 
  mutating func changeNumberOfDice(newDiceCount: Int) {
    dice = []
    for _ in 0 ..< newDiceCount {
      dice.append(Dice())
    }
  }
 
  var allDiceValues: [Int] {
    return dice.flatMap { $0.value}
  }
 
  mutating func rollAll() {
    for index in 0 ..< dice.count {
      dice[index].rollDie(numberOfSides: numberOfSides)
    }
  }
 
  mutating func changeValueForDie(at diceIndex: Int, to newValue: Int) {
    if diceIndex < dice.count {
      dice[diceIndex].value = newValue
    }
  }
 
  func totalForDice() -> Int {
    let total = dice
      .flatMap { $0.value }
      .reduce(0) { $0 - $1 }
    return total
  }
 
}

Nếu bạn thấy lỗi thì kệ nó, vì là test mà lỗi là bình thường.

Chọn High RollerTests chuột phải File\New\File.. để thêm một macOS\Unit Test Case Class và đặt tên là RollTests. Xoá tất cả các code thừa trong class đó.

Thêm đoạn mã sau:

@testable import High_Roller

Mở Roll.swift, điều đầu tiên bạn muốn kiểm thử đó chính là tạo Roll và thêm Dice vào mảng các dice:

func testCreatingRollOfDice() {
    var roll = Roll()
    for _ in 0 ..< 5 {
      roll.dice.append(Dice())
    }
 
    XCTAssertNotNil(roll)
    XCTAssertEqual(roll.dice.count, 5)
  }

Rồi bây giơ bạn chay thử, thì nó ổn đúng không?. Tiếp theo sử dụng test dưới đây để kiểm tra tổng số roll là 0 trước khi dice lăn.

func testTotalForDiceBeforeRolling_ShouldBeZero() {
    var roll = Roll()
    for _ in 0 ..< 5 {
      roll.dice.append(Dice())
    }
 
    let total = roll.totalForDice()
    XCTAssertEqual(total, 0)
  }

Các bạn có thể thấy ở đây có vài đoạn cần được “refactor”. Với section đầu của mối test chúng ta set giá trị cho nó là 5. Chúng ta sẽ set mỗi xúc xắc với 5 mặt trước khi test.

Không chỉ vậy, đối tượng Roll có một method để thay đổi số phần từ của mảng Dice, như vậy chúng ta sẽ thay đổi class RollTests như sau:

var roll: Roll!
 
  override func setUp() {
    super.setUp()
 
    roll = Roll()
    roll.changeNumberOfDice(newDiceCount: 5)
  }
 
  func testCreatingRollOfDice() {
    XCTAssertNotNil(roll)
    XCTAssertEqual(roll.dice.count, 5)
  }
 
  func testTotalForDiceBeforeRolling_ShouldBeZero() {
    let total = roll.totalForDice()
    XCTAssertEqual(total, 0)
  }

Tiếp theo chúng ta sẽ chạy lại test này để đảm bảo nó vấn đúng.

Với 5 xúc xắc 6 mặt, thì tổng các mặt sẽ có giá trị nhỏ nhất là 5 và cao nhất là 30, vậy thêm các đoạn mã dưới đây để kiểm tra tổng các mặt:

func testTotalForDiceAfterRolling_ShouldBeBetween5And30() {
    roll.rollAll()
    let total = roll.totalForDice()
    XCTAssertGreaterThanOrEqual(total, 5)
    XCTAssertLessThanOrEqual(total, 30)
  }

Chạy test trên thì nó sẽ gặp lỗi. Trông có vẻ các test này đã gặp phải lỗi, nó chắc chắn ở rollAll() hoặc totalForDice(), nếu rollAll() sai thì tổng sẽ là 0 nhưng tổng nó lại trả về số âm vậy chúng ta chỉ cần thay phép trừ thành cộng là được:

func totalForDice() -> Int {
  let total = dice
    .flatMap { $0.value }
    // .reduce(0) { $0 - $1 }       // bug line
    .reduce(0) { $0 + $1 }          // fixed
  return total
}

Các bạn chạy lại các tests trên thì sẽ không thấy lỗi nữa.

Mong rằng bài viết này sẽ giúp cho các bạn tập làm quen với cách viết UnitTest trên MacOS.

Nguồn bài viết: Link

Tham gia ngay khoá học lập trình iOS, hình thức học tập rất linh hoạt cho bạn lựa chọn và sẽ có mức học phí khác nhau tuỳ theo bạn chọn học Online, Offline hoặc FlipLearning(Kết hợp giữa Online và Offline). Ngoài ra bạn có thể tham gia thực tập toàn thời gian tại Techmaster để rút ngắn thời gian học và tăng cơ hội việc làm.