Mặc dù bây giờ đã rất rõ ràng, kiểm thử là một phần rất quan trọng trong quá trình phát triển phần mềm. Theo một cách nào đó, các thử nghiệm là một cấu trúc xung quanh phần mềm, cung cấp sự tự tin cho việc thay đổi cấu trúc của nó (như việc phát triển tính năng, maintain) trong khi đảm bảo không ảnh hưởng gì đến logic cũ.
Với mục đích của bài viết này, mình đã tạo một bản demo đơn giản giúp chúng ta có cái nhìn tổng quát về cấu trúc cũng nhưng những yêu cầu cơ bản khi bắt đầu làm việc với UnitTest trên Android. Bản demo là màn hình đăng nhập. Sẽ hiển thị lỗi trong trường hợp tên người dùng hoặc mật khẩu trống, hoặc xác thực không chính xác, còn nếu đúng nó sẽ mở ra màn hình chính . Để làm cho nó đơn giản nhất có thể, mình quyết định chỉ sử dụng thư viện espresso và mockK.
1. Sự khác nhau giữa Local Unit Test và Instrumented Test
Chúng ta có thể để ý thấy là có 2 thư mục test trong ứng dụng android như vầy
Local Unit Test (Kiểm thử đơn vị) Thư mục src/androidTest dành cho các bài kiểm tra đơn vị liên quan đến thiết bị Android.
Instrumented Test (Kiểm thử thiết bị) Thư mục src/test dành cho thử nghiệm đơn vị thuần túy không liên quan đến khung Android. Bạn có thể chạy thử nghiệm ở đây mà không cần chạy trên thiết bị thực hoặc trên trình giả lập.
Sự khác biệt giữa Kiểm tra Đơn vị và Kiểm tra Thiết bị là gì?
Thông thường Local Unit Test được gọi là “bài kiểm thử cục bộ” hoặc “bài kiểm thử đơn vị cục bộ”. Lý do chính cho điều này dường như là để xác minh rằng logic nghiệp vụ đang hoạt động bình thường mà không cần thiết bị hoặc trình giả lập đi kèm.
Instrumented Test chạy trên thiết bị hoặc trình giả lập. Trong nền, ứng dụng của bạn sẽ được cài đặt và sau đó một ứng dụng thử nghiệm cũng sẽ được cài đặt, ứng dụng này sẽ kiểm soát ứng dụng của bạn, dùng bữa trưa và chạy thử nghiệm giao diện người dùng nếu cần. Các bài kiểm tra công cụ cũng có thể được sử dụng để kiểm tra UI và không chứa logic. Chúng đặc biệt hữu ích khi bạn cần kiểm tra mã có sự phụ thuộc vào ngữ cảnh.
Bạn có thể sử dụng cả hai cùng lúc. Sử dụng Instrumented Test để kiểm thử UI và sử dụng Local Unit Test để kiểm tra các logic. Các phương pháp để viết bài kiểm thử gần như giống nhau.
Bây giờ, hãy cùng taọ một ứng dụng mẫu. Mình sẽ tạo một ứng dụng đơn giản với mục đích duy nhất là đăng nhập. Mọi người có thể tham khảo qua source code tại đây.
2. Instrumented Test
Với ứng dụng demo chúng ta có 2 màn hình là Login và Main. Ở đây mình sẽ tập trung vào test UI của màn hình Login còn màn hình Main các bạn có thể xây dựng thêm giao diện mong muốn và thực hành dựa vào demo này của mình.
Nào giờ cùng nhau hình dung với giao diện này chúng ta có thể liệt kê ra một số test case cơ bản như nếu tên người dùng hoặc mật khẩu trống, màn hình sẽ hiển thị lỗi. Ngoài ra, chúng ta cần có các bài kiểm tra để đảm bảo rằng đối với xác thực không chính xác sẽ xuất hiện lỗi và đối với xác thực đúng, màn hình Main sẽ mở.
Đây là các test case mình sẽ sử dụng để test cho phần UI màn hình Login:
Login title có hiển thị
Text login tittle hiển thị chính xác
UsernameInput có hiển thị
Text hint của UsernameInput chính xác
PasswordInput có hiển thị
Text hint của PasswordInput chính xác
Button login có hiển thị
Text của Button login chính xác
Text “Username and Password cannot be blank or have spaces” hiển thị đúng khi username chứa khoảng trắng
Text “Username and Password cannot be blank or have spaces” hiển thị đúng khi password chứa khoảng trắng
Text “Login fail!” hiển thị khi đăng nhập thất bại
Di chuyển đến màn hình MainActivity khi đăng nhập thành công
LoginActivityTest
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
@Rule
@JvmField
val rule = ActivityTestRule(LoginActivity::class.java)
@Test
fun shouldContainLoginTitle() {
loginScreen {
isDisplayedLoginTitle()
}
}
@Test
fun loginTitleShouldApplyCorrectText() {
loginScreen {
loginTitle {
hasCorrectTitle()
}
}
}
@Test
fun shouldContainUsernameInput() {
loginScreen {
isDisplayedUsernameInput()
}
}
@Test
fun usernameInputShouldApplyCorrectHint() {
loginScreen {
usernameEditor {
hasCorrectHint()
}
}
}
@Test
fun shouldContainPasswordInput() {
loginScreen {
isDisplayedPasswordInput()
}
}
@Test
fun passwordInputShouldApplyCorrectHint() {
loginScreen {
passwordEditor {
hasCorrectHint()
}
}
}
@Test
fun shouldContainLoginButton() {
loginScreen {
isDisplayedLoginButton()
}
}
@Test
fun loginButtonShouldApplyCorrectText() {
loginScreen {
loginButton {
hasCorrectTitle()
}
}
}
@Test
fun loginWithUsernameHaveBlankShouldDisplayError() {
loginScreen {
typeUsername(" ")
typePassword("Pass@12")
} submit {
displayBlankUsernameOrPasswordError()
}
}
@Test
fun loginWithPasswordHaveBlankShouldDisplayError() {
loginScreen {
typeUsername("larn203")
typePassword(" ")
} submit {
displayBlankUsernameOrPasswordError()
}
}
@Test
fun loginFailShouldDisplayError() {
loginScreen {
typeUsername("123456")
typePassword("123456")
} submit {
displayLoginFail()
}
}
@Test
fun successfulLoginShouldOpenMainScreen() {
loginScreen {
typeUsername("larn203")
typePassword("Pass@12")
} submit {
openMainActivity()
}
}
}
Wow cũng không khó như chúng ta nghĩ phải không. Để hiểu hơn về cách sử dụng Espresso các bạn có thể tham khảo thêm về bài viết này.
3. Local Unit Test
Bây giờ, mình sẽ kiểm tra đơn vị các lớp sau: ValidateUtils, LoginViewModel
Tạo một tệp java mới và đặt tên là ValidateUtilsTtest
Đầu tiên, chúng tôi sẽ kiểm tra lớp ValidateUtils. Lớp ValidateUtils có nhiệm vụ kiểm tra giá trị của username hoặc password có đúng cấu trúc mong muốn của nhà phát triển không. Nên các quy tắc kiểm tra cũng tuỳ thuộc vào từng ứng dụng. Ở đây mình đưa ra một số ví dụ tổng quan nhất nếu chưa đủ các bạn có thể thêm theo mong muốn nó cúng như một cách thực hành. Còn bây giờ chúng ta phải đưa ra tất cả các trường hợp đầu vào mà chúng tôi có thể nghĩ ra.
Test case cho phương thức isWhitespace
Đầu vào có khoảng trắng -> expect: return true
Đầu vào không có khoảng trắng -> expect: return false
Test case cho phương thức validateUsername và validatePassword
Đầu vào: rỗng -> Expect: return false
Đầu vào: trống -> Expect: return false
Đầu vào: có khoảng trắng -> Expect: return false
Đầu vào: có khoảng trắng ở đầu -> Expect: return false
Đầu vào: có khoảng trắng ở giữa -> Expect: return false
Đầu vào: có khoảng trắng ở cuối -> Expect: return false
Đầu vào: chính xác -> Expect: return true
ValidateUtilsTtest
class ValidateUtilsTest {
@MockK
private lateinit var validateUtils: ValidateUtils
@Before
fun setUp() {
validateUtils = ValidateUtils()
}
@Test
fun validateIsWhiteSpaceReturnTrue() {
assertTrue(validateUtils.isWhitespace(" "))
}
@Test
fun validateIsWhiteSpaceReturnFalse() {
assertFalse(validateUtils.isWhitespace("123456"))
}
@Test
fun validateUsernameNullReturnFalse() {
assertFalse(validateUtils.validateUsername(null))
}
@Test
fun validatorUserNameEmptyReturnFalse() {
assertFalse(validateUtils.validateUsername(""))
}
@Test
fun validatorUserNameBlankReturnFalse() {
assertFalse(validateUtils.validateUsername(" "))
}
@Test
fun validatorUserNameBlankAtStartReturnFalse() {
assertFalse(validateUtils.validateUsername(" 123456"))
}
@Test
fun validatorUserNameBlankAtBetweenReturnFalse() {
assertFalse(validateUtils.validateUsername("123 456"))
}
@Test
fun validatorUserNameBlankAtEndReturnFalse() {
assertFalse(validateUtils.validateUsername("123456 "))
}
@Test
fun validateUsernameCorrect() {
assertTrue(validateUtils.validateUsername("123456"))
}
@Test
fun validatePasswordNullReturnFalse() {
assertFalse(validateUtils.validatePassword(null))
}
@Test
fun validatorPasswordEmptyReturnFalse() {
assertFalse(validateUtils.validatePassword(""))
}
@Test
fun validatorPasswordBlankReturnFalse() {
assertFalse(validateUtils.validatePassword(" "))
}
@Test
fun validatorPasswordBlankAtStartReturnFalse() {
assertFalse(validateUtils.validatePassword(" 123456"))
}
@Test
fun validatorPasswordBlankAtBetweenReturnFalse() {
assertFalse(validateUtils.validatePassword("123 456"))
}
@Test
fun validatorPasswordBlankAtEndReturnFalse() {
assertFalse(validateUtils.validatePassword("123456 "))
}
@Test
fun validatorPasswordLessThreeCharacterReturnFalse() {
assertFalse(validateUtils.validatePassword("12"))
}
@Test
fun validatePasswordCorrect() {
assertTrue(validateUtils.validatePassword("123456"))
}
}
Mặc dù hầu hết các mã đều có thể tự giải thích, nhưng đây là một số điều bạn có thể không biết :
@Test: Là một chú thích được cung cấp bởi JUnit Framework để đánh dấu một phương pháp là một trường hợp thử nghiệm. Như bạn có thể thấy ở đây, mỗi phương pháp là một trường hợp thử nghiệm kiểm tra trường đầu vào để tìm đầu vào khả thi. Điều này hướng dẫn trình biên dịch coi phương pháp như một trường hợp thử nghiệm trong bộ thử nghiệm.
assertTrue(): Là một phương thức được cung cấp bởi JUnit Framework để xác nhận (buộc) giá trị bên trong dấu ngoặc đơn của nó là TRUE. Nếu giá trị bên trong dấu ngoặc đơn đánh giá là sai, trường hợp kiểm tra không thành công.
assertFalse(): Giống như phương thức khẳng định, ngoại trừ việc nó khẳng định đối số bên trong dấu ngoặc đơn là sai thay vì đúng. Nếu tham số truyền vào là true, trường hợp thử nghiệm không thành công.
Ở đây mình có sử dụng thư viện MockK, cung cấp hỗ trợ cho các cấu trúc và tính năng của ngôn ngữ Kotlin. MockK xây dựng proxy cho các lớp mock. Điều này gây ra một số suy giảm hiệu suất, nhưng những lợi ích tổng thể mà MockK mang lại cho chúng ta là xứng đáng. Việc mock các object cho phép bạn test trên các đối tượng độc lập. Bất kỳ sự phụ thuộc vào object nào khi test đều có thể được mock để cung cấp các điều kiện cố định, do đó, đảm bảo các test luôn ổn định và rõ ràng.
Mockito là một framework phổ biến được dùng bởi các Java developer và vô cùng mạnh mẽ. Nhưng nó lại có một vài khó chịu khi được áp dụng cho Kotlin. MockK, được thiết kế đặc biệt cho Kotlin, sẽ mang lại cho chúng ta một trải nghiệm thoải mái hơn nhiều. Hãy xem chúng ta sử dụng mockK như thế nào trong việc kiểm thử LoginViewModel.
Test case cho LoginViewModel:
Validate username return false -> Expect: checkValidate return false
Validate password return false -> Expect: checkValidate return true
Validate username và password return true -> Expect: checkValidate return true
LoginFail
LoginSuccess
LoginViewModelTest
class LoginViewModelTest : BaseTestViewModel<LoginViewModel>() {
@Rule
@JvmField
val instantTaskExecutorRule:
TestRule = InstantTaskExecutorRule()
@MockK
private lateinit var loginRepository: LoginRepository
@MockK
private lateinit var validateUtils: ValidateUtils
private val loginTest = LoginResponse(1, "larn203", "Pass@12")
override fun setUp() {
super.setUp()
viewModel =
spyk(LoginViewModel(loginRepository, validateUtils))
}
@Test
fun checkValidateUserNameFail() {
//Given
every { validateUtils.validateUsername(any()) }
returns false
every { validateUtils.validatePassword(any()) }
returns true
//Then
assertFalse(viewModel.checkValidate())
}
@Test
fun checkValidatePasswordFail() {
//Given
every { validateUtils.validateUsername(any()) }
returns true
every { validateUtils.validatePassword(any()) }
returns false
//Then
assertFalse(viewModel.checkValidate())
}
@Test
fun checkValidateSuccess() {
//Given
every { validateUtils.validateUsername(any()) }
returns true
every { validateUtils.validatePassword(any()) }
returns true
//Then
assertTrue(viewModel.checkValidate())
}
@Test
fun loginFail() {
//Given
every { viewModel.checkValidate() } returns true
every { loginRepository.userLogin(any()) } answers {
Single.error(Throwable("error"))
}
//When
viewModel.onLogin()
//Then
verify(exactly = 1) {
viewModel.checkValidate()
viewModel.onLoadFail()
}
}
@Test
fun loginSuccess() {
//Given
every { viewModel.checkValidate() } returns true
every { loginRepository.userLogin(any()) } answers {
Single.just(loginTest)
}
//When
viewModel.onLogin()
//Then
verify(exactly = 1) {
viewModel.checkValidate()
viewModel.onLoadSuccess(any())
}
}
}
Ở đây bạn gặp các chú thích mới:
@MockK: Như đã giải thích ở trên, nó tạo ra một đối tượng giả được lớp cần kiểm tra để hoạt động bình thường. Có 2 cách tạo đối tượng Mock là sử dụng anotation @MockK hoặc sử dụng phương thức tĩnh mockk().
@Before: Dùng Anotation này cho function mà chúng ta muốn nó chạy trước mọi function test khác. Thường thì chúng ta sẽ dùng cho function init. Như ở ví dụ trên mình đã gọi. Ở đây chúng tôi khởi tạo viewModel
@After: Dùng Anotatio này cho function mà chúng ta muốn nó chạy cuối cùng sau mọi function khác.
@Test: Anotation dùng cho function có nhiệm vụ test. Chúng ta sẽ viết test code trong funcction này.
@SpyK: được dùng để tạo một đối tượng spy. Đối tượng spy là đối tượng bán ảo, hay nói cách khác nó vừa là đối tượng thực, vừa là đối tượng ảo. Vừa là đối tượng thực vì nó hoàn toàn có thể thực hiện các method của một đối tượng thực một cách chính xác, không cần stub trước giá trị để trả về như đối tượng mock. Vừa là đối tượng ảo vì nó có thể thực hiện các câu lệnh của một đối tượng mock. Có 2 cách tạo đối tượng Spy là sử dụng anotation @SpyK hoặc sử dụng phương thức tĩnh spyk(). Trong ví dụ, mình đã sử dụng phương pháp spyk để tạo một đối tượng gián điệp.
Làm thế nào mock một Exception với every?
Khá đơn giản, chúng ta có thể ném một ngoại lệ bên trong every{} và dùng verify{} để xác minh onLoadFail() đã được gọi hay chưa như đoạn code dưới đây:
@Test
fun loginFail() {
//Given
every { viewModel.checkValidate() } returns true
every { loginRepository.userLogin(any()) } answers {
Single.error(Throwable("error"))
}
//When
viewModel.onLogin()
//Then
verify(exactly = 1) {
viewModel.checkValidate()
viewModel.onLoadFail()
}
}
Để kiểm thử luồng Login thành công chúng ta phải đảm bảo ứng dụng phải gọi được đến server và từ server phải trả về kết quả như mong muốn. Trong test case này chúng ta sẽ:
Mô phỏng việc gọi đến Server
Mô phỏng phản hồi với một response
Xác minh onLoadSuccess() được gọi
@Test
fun loginSuccess() {
//Given
every { viewModel.checkValidate() } returns true
every { loginRepository.userLogin(any()) } answers {
Single.just(loginTest)
}
//When
viewModel.onLogin()
//Then
verify(exactly = 1) {
viewModel.checkValidate()
viewModel.onLoadSuccess(any())
}
}
4. Tổng kết
Tổng kết đây là các bước để tạo một bài kiểm thử đơn vị (nó có thể phức tạp hơn, nhưng đối với người mới bắt đầu, mình nghĩ điều này là đủ):
Hãy nghĩ về tất cả các trường hợp thử nghiệm có thể xảy ra.
Tạo một phương thức cho mỗi trường hợp thử nghiệm và chú thích nó bằng @Test
Tạo phương thức @Before và khởi tạo thư viện MockK
Viết các trường hợp thử nghiệm của bạn bằng các phương pháp như when, is, assertThat / False / True, v.v.
Luôn luôn kết thúc các phương thức mock tại @After
Comentarios