Terkadang kita sebagai Developer tidak menulis Unit Test bukan karena tidak mau, tapi mungkin karena codebase yang ada itu cukup sulit untuk di-test, apalagi jika sudah cukup panjang kode programnya, pasti akan banyak memakan waktu untuk melakukan refactoring agar bisa di-test.
Nah, itulah pentingnya untuk mempertimbangkan Testing dari awal, yang saya maksud bukan hanya mencentang "Include Tests" di Xcode pada saat kita membuat project baru :v namun kita juga harus mendesain kode kita dari awal agar mudah untuk di-test.
Karena mindset-nya sedikit berbeda, kalau kita tidak mempertimbangkan Testing dari awal mungkin kita akan pakai Singleton di mana-mana, melakukan hard-code terhadap dependensi (bukan melalui Dependency Injection), dll. sehingga kode kita akan sulit untuk di-test.
Pada artikel ini saya akan membagikan beberapa tips dalam menulis kode yang testable atau mudah untuk di-test pada bahasa pemrograman Swift.
Beberapa orang mungkin menganggap bahwa Unit Test itu tidak ada gunanya, hanya buang-buang waktu, kan lebih baik waktunya digunakan untuk mendevelop fitur?
Padahal sebaliknya, Unit Test justru bisa saving our time! Misalnya jika kita ingin melakukan perubahan pada kode kita seperti menambahkan fitur baru, melakukan refactoring, dll. kita tidak perlu testing secara manual yang akan memakan banyak waktu hanya untuk memastikan aplikasi kita tetap berjalan dengan baik.
Unit Test juga bisa berfungsi sebagai dokumentasi dari program yang kita buat, karena jika kita melihat dari sisi kode kan hanya ada tipe datanya aja ya? nah dengan Unit Test, kita bisa melihat input dan output secara konkrit pada sebuah function.
Dan dengan Unit test, kita bisa lebih percaya diri dengan kode yang kita tulis, karena jika kita melakukan perubahan yang ternyata menyebabkan bug pada aplikasi kita, maka bug tersebut akan terdeteksi lebih awal, jadi nggak akan sampai ke Team QA atau bahkan ke User.
Berikut adalah beberapa tips dalam menulis kode yang mudah untuk di-test pada bahasa pemrograman Swift.
Jika kita mempunyai function yang terlalu panjang, mungkin bisa kita pecah menjadi beberapa function. karena Unit Test kan tujuannya untuk melakukan testing terhadap suatu unit kode, jadi kalau satu function memiliki banyak responsibility, maka akan sulit untuk di-test.
Contohnya seperti ini:
โ Contoh yang salah1struct ProductService {2 func getProducts() async throws -> [Product] {3 let url = URL(string: "https://website.com/api/products")!4 let (data, _) = try await URLSession.shared.data(from: url)5 return try JSONDecoder().decode([Product].self, from: data)6 }7}
โ Contoh yang benar1struct ProductService {2 func getProducts() async throws -> [Product] {3 let data = try await fetchProducts()4 return try decodeProducts(data)5 }67 internal func fetchProducts() async throws -> Data {8 let url = URL(string: "https://website.com/api/products")!9 let (data, _) = try await URLSession.shared.data(from: url)10 return data11 }1213 internal func decodeProducts(_ data: Data) throws -> [Product] {14 return try JSONDecoder().decode([Product].self, from: data)15 }16}
Pada contoh di atas, kita pecah function getProducts menjadi 2 function, yaitu:
function fetchProducts untuk melakukan Network Request.
function decodeProducts untuk Parsing JSON.
Sehingga kita bisa test secara terisolasi terhadap masing-masing function tersebut.
๐ Unit Tests1import XCTest2@testable import SampleApp34final class ProductServiceTests: XCTestCase {5 func test_fetchProducts() async throws { ... }67 func test_decodeProducts() throws {8 let json = """9 [10 {11 "name": "iPhone 15",12 "price": 2000000013 }14 ]15 """1617 let data = json.data(using: .utf8)!18 let sut = ProductService()19 do {20 let products = try sut.decodeProducts(data)21 XCTAssertEqual(22 products,23 [Product(name: "iPhone 15", price: 20_000_000)]24 )25 } catch {26 XCTFail(error.localizedDescription)27 }28 }29}
Sebisa mungkin kita harus menghindari Singleton, agar lebih mudah jika kita ingin melakukan mocking atau me-replace suatu dependensi pada saat testing.
Solusinya kita bisa gunakan Dependency Injection.
Dependency Injection adalah teknik untuk memindahkan proses pembuatan instance suatu dependensi dari dalam class atau struct menuju ke luar. Jadi class atau struct tersebut hanya menerima instance, bukan yang membuat instance dari suatu dependensi.
โ Contoh yang salah1struct ProductService {2 func getProducts() async throws -> [Product] {3 let data = try await fetchProducts()4 return try decodeProducts(data)5 }67 internal func fetchProducts() async throws -> Data {8 let url = URL(string: "https://website.com/api/products")!9 let (data, _) = try await URLSession.shared.data(from: url)10 return data11 }1213 internal func decodeProducts(_ data: Data) throws -> [Product] {14 return try JSONDecoder().decode([Product].self, from: data)15 }16}
โ Contoh yang benar1struct ProductService {2 private let urlSession: URLSession3 private let jsonDecoder: JSONDecoder45 init(urlSession: URLSession = URLSession.shared, jsonDecoder: JSONDecoder = JSONDecoder()) {6 self.urlSession = urlSession7 self.jsonDecoder = jsonDecoder8 }910 func getProducts() async throws -> [Product] {11 let data = try await fetchProducts()12 return try decodeProducts(data)13 }1415 internal func fetchProducts() async throws -> Data {16 let url = URL(string: "https://website.com/api/products")!17 let (data, _) = try await urlSession.data(from: url)18 return data19 }2021 internal func decodeProducts(_ data: Data) throws -> [Product] {22 return try jsonDecoder.decode([Product].self, from: data)23 }24}
Agar lebih mudah dalam menggunakan ProductService, kita bisa tambahkan Default Parameter pada Initializer-nya.
Default Parameter1struct ProductService {2 private let urlSession: URLSession3 private let jsonDecoder: JSONDecoder46 init(urlSession: URLSession = URLSession.shared, jsonDecoder: JSONDecoder = JSONDecoder()) {7 self.urlSession = urlSession8 self.jsonDecoder = jsonDecoder9 }1011 ...
Jadi, apabila kita ingin mocking response pada urlSession, maka akan menjadi lebih mudah, contohnya seperti ini:
๐ Unit Tests1import XCTest2@testable import SampleApp34final class ProductServiceTests: XCTestCase {5 func test_fetchProducts_failed() async throws {6 MockURLProtocol.requestHandler = { request in8 let response = HTTPURLResponse(url: URL(string: "https://mock.com")!, statusCode: 500, httpVersion: nil, headerFields: nil)!9 return (response, nil)10 }1112 let configuration = URLSessionConfiguration.default13 configuration.protocolClasses = [MockURLProtocol.self]14 let urlSession = URLSession.init(configuration: configuration)1516 let sut = ProductService(urlSession: urlSession)17 do {18 let _ = try await sut.getProducts()19 XCTFail()20 } catch {21 XCTAssert(true)22 }23 }24}
Dependency Inversion adalah satu dari 5 prinsip dalam SOLID.
Sederhananya, jika kita punya Object A yang mempunyai dependensi ke Object B, maka Object A tidak boleh punya dependensi langsung ke Object B, tetapi harus melalui abstraction. di Swift, kita bisa menggunakan protocol untuk membuat abstraction tersebut.
โ Contoh yang salah1class ProductViewModel {2 private let productService: ProductService34 init(productService: ProductService) {5 self.productService = productService6 }78 func getTotalProducts() async throws -> Int {9 let products = try await productService.getProducts()10 return products.count11 }12}
โ Contoh yang benar1protocol ProductServiceProtocol {2 func getProducts() async throws -> [Product]3}45class ProductViewModel {6 private let productService: ProductServiceProtocol78 init(productService: ProductServiceProtocol) {9 self.productService = productService10 }1112 func getTotalProducts() async throws -> Int {13 let products = try await productService.getProducts()14 return products.count15 }16}
Pada contoh di atas, ProductViewModel tidak mempunyai dependensi langsung ke ProductService, namun melalui sebuah abstraction bernama ProductServiceProtocol.
Jadi, kita akan lebih mudah untuk melakukan mocking pada saat Testing nantinya.
๐ Unit Tests1import XCTest2@testable import SampleApp35struct MockProductService: ProductServiceProtocol {6 func getProducts() async throws -> [Product] {7 return [Product(name: "iPhone 15", price: 20_000_000)]8 }9}1112final class ProductViewModelTests: XCTestCase {13 func test_getTotalProducts() async throws {14 let sut = ProductViewModel(16 productService: MockProductService()17 )1819 do {20 let total = try await sut.getTotalProducts()21 XCTAssertEqual(total, 1)22 } catch {23 XCTFail(error.localizedDescription)24 }25 }26}
Kita bisa membuat Custom Error agar errornya lebih jelas dan terdefinisi dengan baik sehingga kita bisa lebih spesifik dalam pengecekan tipe error pada saat menulis Unit Test.
โ Contoh yang salah1internal func fetchProducts() async throws -> Data {2 let (data, _) = try await urlSession.data(from: url)3 return data4}
โ Contoh yang benar1enum AppError: Error {2 case invalidURL3 case failedToFetchProducts4 case failedToDecodeProducts5}67...89internal func fetchProducts() async throws -> Data {10 do {11 guard let url = URL(string: "https://website.com/api/products") else {12 throw AppError.invalidURL13 }1415 let (data, _) = try await urlSession.data(from: url)16 return data17 } catch {18 throw AppError.failedToFetchProducts19 }20}
๐ Unit Tests1func test_fetchProducts_failed() async throws {2 MockURLProtocol.requestHandler = { request in3 let response = HTTPURLResponse(url: URL(string: "https://mock.com")!, statusCode: 500, httpVersion: nil, headerFields: nil)!4 return (response, nil)5 }67 let configuration = URLSessionConfiguration.default8 configuration.protocolClasses = [MockURLProtocol.self]9 let urlSession = URLSession.init(configuration: configuration)1011 let sut = ProductService(urlSession: urlSession)12 do {13 let _ = try await sut.getProducts()14 XCTFail()15 } catch {17 XCTAssertEqual(error as? AppError, AppError.failedToFetchProducts)18 }19}
Yang terakhir, kita bisa menambahkan Accessibility Identifier pada suatu view agar lebih ringkas dan lebih konsisten pada saat melakukan query untuk UI Test.
Misalnya kita punya UILabel yang berisi text deskripsi yang sangat panjang, nah dengan menambahkan Accessbility Identifier, kita hanya perlu query berdasarkan ID nya saja dan jika kita ingin mengubah text nya, kita tidak perlu mengubah query kita.
โ Contoh yang salah1let descriptionLabel: UILabel = {2 let label = UILabel()3 label.text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."4 label.numberOfLines = 05 return label6}()
โ Contoh yang benar1let descriptionLabel: UILabel = {2 let label = UILabel()3 label.text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."4 label.numberOfLines = 06 label.accessibilityIdentifier = "description"7 return label8}()
๐ UI Tests1import XCTest23final class SampleAppUITests: XCTestCase {4 func test_descriptionLabel_exists() throws {5 let app = XCUIApplication()6 app.launch()79 let descriptionLabel = app.staticTexts["description"]10 XCTAssertTrue(descriptionLabel.exists)11 }12}
Itulah tadi beberapa tips dalam menulis kode yang mudah untuk di-test pada Swift. Meskipun saat ini kita belum ingin menambahkan Unit Testing pada aplikasi kita karena alasan tertentu, menurutku kita tetap harus mendesain kode kita agar mudah untuk di-test. Sehingga jika kita ingin menambahkan Unit Test di masa depan nanti, maka akan menjadi lebih mudah dan tidak perlu banyak refactor.
Oke mungkin itu saja yang bisa saya bagikan kali ini, kalau kamu merasa artikel ini bermanfaat silakan Like & Share artikel ini ke teman-teman kamu atau jika kamu punya pertanyaan, tulis aja di kolom komentar, Thank you! ๐ ๐
Referensi:
Always open to new ideas. ๐๏ธ
Articles that you might want to read.
Otomatisasi build, testing, dan deployment aplikasi iOS ke Testflight & AppStore.
Tutorial membongkar dan memodifikasi aplikasi iOS ๐
Otomatisasi build, testing, screenshot, dan deployment aplikasi iOS ke Testflight & AppStore.