Clean Test là gì ?
NỘI DUNG BÀI VIẾT
Thật dễ dàng để chúng ta có thể viết các “Unit Test” bằng cách sử dụng JUnit và một số thư viện mock. Chúng có thể tạo ra mức độ bao phủ mã khiến một số bên liên quan hài lòng, mặc dù các test case thậm chí có thể không phải là Unit Test và test case có thể cung cấp giá trị đáng nghi vấn. Vậy Clean Test là gì ? Thực hiện Unit Test đúng cách khó hơn rất nhiều so với mọi người nghĩ. Trong bài viết này, tôi sẽ giới thiệu một số phương pháp với mục đích cải thiện khả năng đọc, khả năng bảo trì và chất lượng của các unit test của bạn.
Lý do thất bại
Unit Test sẽ chỉ thất bại nếu có vấn đề với code được test. Một Unit Test cho lớp DBService chỉ nên thất bại nếu có lỗi xảy ra với DBService chứ không phải nếu có lỗi với bất kỳ Class nào khác mà nó phụ thuộc vào. Do đó, trong Unit Test cho DBService, đối tượng khởi tạo duy nhất phải là DBService. Mọi đối tượng khác mà DBService phụ thuộc vào đều nên là stubbed hoặc là mocked.
Nếu không, bạn đang kiểm tra mã ngoài DBService. Mặc dù bạn có thể (không chính xác) nghĩ rằng điều này là đáng tiếc hơn, nhưng việc xác định nguyên nhân gốc rễ của các vấn đề sẽ mất nhiều thời gian hơn. Nếu test case không thành công, có thể là do có vấn đề với bất kỳ một trong nhiều Class nhưng bạn không biết Class nào. Trong khi đó, nếu nó chỉ có thể thất bại do code đang được test bị sai, thì bạn biết rằng bạn sẽ đến gần vị trí của vấn đề nhanh hơn nhiều.
Hơn nữa, suy nghĩ theo cách này sẽ cải thiện bản chất Hướng đối tượng của đoạn code của bạn. Khi một Unit Test chỉ thất bại vì mã được test bị hỏng, điều đó có nghĩa là Unit Test chỉ đang test trách nhiệm của Class và do đó làm rõ chúng. Nếu trách nhiệm của Class không rõ ràng, hoặc nó không thể làm gì nếu không có Class khác, hoặc Class quá tầm thường thì Unit Test là vô nghĩa, nó sẽ đặt ra câu hỏi rằng có điều gì đó không ổn với lớp về tính tổng quát của trách nhiệm .
Ngoại lệ duy nhất để không phải mocked hoặc khai báo một lớp phụ thuộc là nếu bạn đang sử dụng một Class có sẵn từ thư viện Java, ví dụ: String. Không có nhiều stubbe hoặc mock. Hoặc, Class phụ thuộc chỉ đơn giản như vậy, ví dụ như một POJO bất biến, cũng không có nhiều giá trị để tạo mock cho nó.
Stubbing và Mocking
Các thuật ngữ stubbing và mocking thường có thể được sử dụng thay thế cho nhau như thể có cùng một thứ. Chúng không giống nhau. Vậy sự khác biệt là gì? Tóm lại, nếu code của bạn đang được kiểm tra có sự phụ thuộc vào một đối tượng mà nó không bao giờ gọi một phương thức trên đối tượng có các ảnh hướng, thì đối tượng đó sẽ là stubbed.
Trong khi đó, nếu nó phụ thuộc vào một đối tượng mà nó gọi ra các phương thức có các ảnh hưởng thì nó sẽ là mocked. Sao nó lại quan trọng? Bởi vì test case của bạn nên test những thứ khác nhau tùy thuộc vào các loại mối quan hệ mà nó có với các phụ thuộc của nó.
Giả sử đối tượng đang test của bạn là BusinessDelegate. BusinessDelegate nhận yêu cầu chỉnh sửa BusinessEntities. Nó thực hiện một số logic nghiệp vụ đơn giản và sau đó gọi các phương thức trên DBFacade. Vì vậy, code được test trông giống như sau:
class BusinessDelegateSpec {
@Subject
BusinessDelegate businessDelegate
def dbFacade
def setup() {
dbFacade = Mock(DbFacade)
businessDelegate = new BusinessDelegate(dbFacade);
}
def "edit(BusinessEntity businessEntity)"() {
given:
def businessEntity = Stub(BusinessEntity)
// ...
when:
businessDelegate.edit(businessEntity)
then:
1 * dbFacade.update(data)
}
}
Code language: JavaScript (javascript)
Hiểu rõ về sự khác biệt của stub và mock sẽ cải thiện đáng kể chất lượng OO. Thay vì chỉ nghĩ về những gì đối tượng làm, các mối quan hệ và sự phụ thuộc giữa chúng cần được chú trọng hơn nhiều. Giờ đây, các Unit Test có thể giúp thực thi các nguyên tắc thiết kế nếu mà không sẽ bị mất.
Stub và Mock khi được sử dụng đúng
Các bạn tò mò có thể thắc mắc tại sao trong đoạn code mẫu ở trên, dbFacade được khai báo ở cấp Class, trong khi businessEntity được khai báo ở cấp phương thức? Chà, câu trả lời là, code Unit Test dễ đọc hơn nhiều khi nó phản chiếu code đang được test. Trong lớp BusinessDelegate thực tế, sự phụ thuộc vào dbFacade ở cấp độ Class và sự phụ thuộc vào BusinessEntity ở cấp phương thức.
Trong thế giới thực khi một BusinessDelegate được khởi tạo, một phụ thuộc DBFacade sẽ tồn tại, bất cứ khi nào BusinessDelegate được khởi tạo cho một Unit Test, do đó, có thể tồn tại phụ thuộc DBFacade.
Có hai lợi ích khác của việc này:
- Giảm độ dài của code. Ngay cả khi sử dụng Spock, các đoạn Unit vẫn có thể trở nên dài dòng. Nếu bạn di chuyển các phụ thuộc cấp Class ra khỏi Unit Test bạn sẽ giảm độ chi tiết của đoạn code đang test. Nếu Class của bạn phụ thuộc vào bốn Class khác ở cấp Class thì tối thiểu bốn dòng code trong mỗi Unit Test.
- Tính nhất quán. Các nhà phát triển có xu hướng viết các Unit Test theo nhiều cách khác nhau. Tốt nếu họ là người duy nhất đọc code của họ; nhưng điều này hiếm khi xảy ra. Do đó, chúng tôi càng có nhiều nhất quán trong các test case thì chúng càng dễ duy trì. Vì vậy, nếu bạn đọc một đoạn Unit Test mà bạn chưa bao giờ đọc trước đây và ít nhất là thấy các biến được viết và các mock ở những nơi cụ thể vì những lý do cụ thể, bạn sẽ thấy đoạn code Unit Test dễ đọc hơn.
Thứ tự khai báo biến
Khai báo các biến ở đúng vị trí là một khởi đầu tuyệt vời, điều tiếp theo là làm theo thứ tự chúng xuất hiện trong code. Vì vậy, nếu chúng ta có một đoạn code như sau:
public class BusinessDelegate {
private BusinessEntityValidator businessEntityValidator;
private DbFacade dbFacade;
private ExcepctionHandler exceptionHandler;
@Inject
BusinessDelegate(BusinessEntityValidator businessEntityValidator, DbFacade dbFacade, ExcepctionHandler exceptionHandler) {
// ...
// ...
}
public BusinessEntity read(Request request, Key key) {
// ...
}
}
Code language: PHP (php)
Việc đọc các test case sẽ dễ dàng hơn nhiều nếu chúng được xác định theo thứ tự giống như chúng xuất hiện trong Class đang được test.
class BusinessDelegateSpec {
@Subject BusinessDelegate businessDelegate
// class level dependencies in the same order
def businessEntityValidator
def dbFacade
def exceptionHandler
def setup() {
businessEntityValidator = Stub(BusinessEntityValidator)
dbFacade = Mock(DbFacade)
exceptionHandler = Mock(ExceptionHandler)
businessDelegate = new BusinessDelegate(businessEntityValidator, dbFacade, exceptionHandler)
}
def "read(Request request, Key key)"() {
given:
def request = Stub(Request)
def key = Stub(key)
when:
businessDelegate.read(request, key)
then:
// ...
}
}
Code language: JavaScript (javascript)
Đặt tên biến
Các tên biến được sử dụng để đại diện cho các stub và mock phải là các tên giống nhau được sử dụng trong code được sử dụng.
Thậm chí tốt hơn, nếu bạn có thể đặt tên biến giống với loại trong code đang test và không làm mất bất kỳ ý nghĩa kinh doanh nào thì hãy làm điều đó. Các biến tham số được đặt tên là requestInfo và key và các phần khai tương ứng có cùng tên. Điều này dễ thực hiện hơn nhiều so với việc làm như sau:
public void read(Request info, Key someKey) {
}
// corresponding test code
def "read(Request request, Key key)"() {
given:
def aRequest = Stub(Request)
def myKey = Stub(key) // you ill get dizzy soon!
// ...
Code language: JavaScript (javascript)
Tránh sử dụng quá nhiều Stub
Quá nhiều stub (hoặc mock) thường có nghĩa là có gì đó không ổn. Hãy xem xét định luật Demeter. Hãy tưởng tượng một số lời gọi phương thức như sau
List<BusinessEntity> queryBusinessEntities(Request request, Params params) {
// check params are allowed
Params paramsToUpdate = queryService.getParamResolver().getParamMapper().getParamComparator().compareParams(params)
// ...
// ...
}
Code language: PHP (php)
Nó không đủ để khai thác queryService. Bây giờ bất cứ thứ gì được trả về bởi getParamResolver () phải được khai báo và tệp khai đó phải có getParamMapper () bị khai trừ, sau đó phải có getParamComoparator () được khai thác và sau đó là so sánh Params () được khai thác. Ngay cả với một khung công tác đẹp như Spock, giảm thiểu sự dài dòng, bạn sẽ có một vài dòng mã thử nghiệm chỉ sơ khai cho một dòng mã Java đang được thử nghiệm !!!
def "queryBusinessEntities()"() {
given:
def params = Stub(Params)
def paramResolver = Stub(ParamResolver)
queryService.getParamResolver() = paramResolver
def paramMapper = Stub(ParamMapper)
paramResolver.getParamMapper() >> paramMapper
def paramComparator = Stub (ParamComparator)
paramMapper.getParamComparator() >> paramComparator
Params paramsToUpdate = Stub(Params)
paramComparator.comparaParams(params) >> paramsToUpdate
when:
// ...
then:
// ...
}
Code language: CSS (css)
Hãy xem một dòng Java đó làm gì với bài kiểm tra đơn vị của chúng tôi. Nó thậm chí còn tồi tệ hơn nếu bạn không sử dụng một cái gì đó như Spock. Giải pháp là tránh gọi phương thức kính thiên văn và cố gắng chỉ sử dụng các phụ thuộc trực tiếp.
Trong trường hợp này, chỉ cần tiêm trực tiếp paramComparator vào lớp của chúng ta. Sau đó, mã trở thành …
List<BusinessEntity> queryBusinessEntities(Request request, Params params) {
// check params are allowed
Params paramsToUpdate = paramComparator.compareParams(params)
// ...
// ...
}
Code language: PHP (php)
và đoạn test case sẽ trở thành
setup() {
// ...
// ...
paramComparator = Stub (ParamComparator)
businessEntityDelegate = BusinessEntityDelegate(paramComparator)
}
def "queryBusinessEntities()"() {
given:
def params = Stub(Params)
Params paramsToUpdate = Stub(Params)
paramComparator.comparaParams(params) >> paramsToUpdate
when:
// ...
then:
// ...
}
Code language: JavaScript (javascript)
Cú pháp Gherkin
Một đoạn unit test kém có những điều khủng khiếp như xác nhận ở khắp nơi. Nó rất nhanh có thể buồn nôn.
Những thứ sơ đồ luôn dễ theo dõi hơn. Đó là lợi thế thực sự của cú pháp Gherkin. Kịch bản được thiết lập trong: always. Khi nào là kịch bản và sau đó là nơi chúng tôi kiểm tra những gì chúng tôi mong đợi.
Sử dụng một cái gì đó như Spock có nghĩa là bạn có một DSL đẹp, gọn gàng để các giá trị đã cho :, when: ,, và then: có thể cùng nằm trong một phương pháp thử nghiệm.
Thu hẹp khi đã mở rộng
Nếu một unit test là test bốn phương thức, nó có phải một unit test? Hãy xem xét đoạn unit test dưới đây:
def "test several methods" {
given:
// ...
when:
def name = personService.getname();
def dateOfBirth = personService.getDateOfBirth();
def country = personService.getCountry();
then:
name == "tony"
dateOfBirth == "1970-04-04"
country == "Ireland"
}
Code language: JavaScript (javascript)
Đầu tiên, nếu Jenkins nói với bạn điều này không thành công, bạn sẽ phải root xung quanh và tìm ra phần nào của lớp là sai. Vì đoạn test không tập trung vào một phương thức cụ thể nên bạn không biết ngay phương thức nào không thành công. Thứ hai, nếu getName () không thành công, làm thế nào để bạn biết liệu getDateOfBirth () và getCountry () đang hoạt động?
Thử nghiệm dừng ở lần thất bại đầu tiên. Vì vậy, test không thành công, bạn thậm chí không biết nếu bạn có một phương thức không hoạt động hoặc ba phương thức không hoạt động. Bạn có thể đi xung quanh để nói với mọi người rằng bạn có độ phủ mã 99% và một bài kiểm tra không thành công nhưng không rõ bạn đã bỏ sót điều gì!
Hơn nữa, điều gì thường dễ sửa chữa hơn? Một đoạn testnhỏ không đạt hay một đoạn test dài không thành công? Không có giải thưởng cho việc làm đúng!
Một test case sẽ kiểm tra một tương tác với lớp bạn đang kiểm tra. Bây giờ, điều này không có nghĩa là bạn chỉ có thể có một khẳng định, nó có nghĩa là bạn nên có thời điểm hẹp và khi đó rộng.
Vì vậy, chúng ta hãy thu hẹp khi đầu tiên. Lý tưởng nhất là một dòng mã. Một dòng mã phù hợp với phương thứcbạn đang thử nghiệm đơn vị.
def "getName()" {
given:
// ...
when:
def name = personService.getname();
then:
name == "tony"
}
def "getDateOfBirth()" {
given:
// ...
when:
def dateOfBirth = personService.getDateOfBirth();
then:
dateOfBirth == "1970-04-04"
}
def "getCountry()" {
given:
// ...
when:
def country = personService.getCountry();
then:
country == "Ireland"
}
Code language: CSS (css)
Bây giờ chúng ta có thể có cùng một độ phủ của code, nếu getName () không thành công nhưng getCountry () và getDateOfBirth () vượt qua. Tuy nhiên, có một vấn đề với getName () chứ không phải getCountry () và getDateOfBirth (). Nắm bắt được mức độ chi tiết của quyền kiểm tra là rất quan trọng. Tốt nhất nên là một đơn vị thử nghiệm tối thiểu cho mọi phương thức không riêng tư và hơn thế nữa khi bạn tính đến các thử nghiệm không đúng, v.v.
Bây giờ, hoàn toàn tốt nếu có nhiều xác nhận trong một unit test. Ví dụ, giả sử chúng ta có một phương thức ủy quyền cho các lớp khác.
Hãy xem xét một phương thức resynceCache () mà khi triển khai nó gọi hai phương thức khác trên một đối tượng cacheService, clear () và reload ().
def "resyncCache()" {
given:
// ...
when:
personService.resyncCache();
then:
1 * cacheService.clear()
1 * cacheService.reload()
}
Code language: CSS (css)
Trong trường hợp này, sẽ không hợp lý nếu có hai Unit Test riêng biệt. “Khi nào” giống nhau và nếu thất bại, bạn biết ngay phương thức nào bạn phải xem. Có hai Unit Test riêng biệt chỉ có nghĩa là nỗ lực gấp đôi với ít lợi ích. Điều tinh tế cần thực hiện ngay tại đây là đảm bảo các khẳng định của bạn theo đúng thứ tự. Chúng phải theo thứ tự như khi thực thi mã.
Vì vậy, clear () được gọi trước khi reload (). Nếu test không thành công tại clear (), không có nhiều điểm để test reload () vì phương thức bị hỏng. Nếu bạn không làm theo mẹo về thứ tự xác nhận và xác nhận khi reload() trước và được báo cáo là không thành công, bạn sẽ không biết liệu clear () được cho là xảy ra trước thậm chí đã xảy ra hay chưa.
Kết luận
Clean Unit Test là một phần thiết yếu để đạt được cơ sở mã có thể bảo trì. Đó là điều tối quan trọng nếu bạn muốn có thể phát hành thường xuyên và nhanh chóng. Nó cũng giúp bạn yêu thích Kỹ thuật phần mềm của mình hơn!
Leave a Reply