TDD cycle

Cách thực hành Test-Driven Development (TDD) – JamesShore


Test-Driven Development Bài dịch từ: http://www.jamesshore.com/Agile-Book/test_driven_development.html

TDD giúp chúng ta viết ra những đoạn code tốt được thiết kế tốt và được test cẩn thận thông qua nhiều bước nhỏ nhưng chắc chắn.

“Điều mà các ngôn ngữ lập trình thực sự cần là hướng dẫn sử dụng: “Do what I mean” – một câu nói đùa trong giới lập trình. “Làm điều mà ý tôi nói thế chứ đừng có làm điều tôi nói”. Lập trình đòi hỏi rất khắt khe. Nó yêu cầu sự hoàn hảo, bền vững, cần nhiều tháng, nhiều năm cố gắng. Tuyệt vời nhất, những lỗi trong lập trình làm cho code không thể compile. Tệ nhất, chúng tạo nên những bug mà âm thầm chờ đợi đến đúng thời điểm và tạo ra những thiệt hại ghê gớm nhất.

Con người chẳng bao giờ hoàn hảo.  Vì vậy, các phần mềm luôn luôn có lỗi.

Thật là tuyệt nếu mà có 1 công cụ nào đó có thể báo cho bạn biết những lỗi lập trình của bạn ngay lập tức khi bạn vừa tạo ra nó  . Một công cụ tuyệt vời đén mức mà giúp chúng ta gần như không cần phải debug nữa?

Thực sự thì có một công cụ như vậy, hoặc nói cách khác, một kỹ thuật. Đó là “dùng test để lèo lái việc phát triển phần mềm” – Test-Driven Development (TDD). TDD thực sự giúp chúng ta đáp ứng được những đòi hỏi khắt khe của lập trình ở trên.

 Test-Driven Development (TDD) là một chu trình siêu nhanh gồm các bước testing, coding và refactoring. Khi code  một chức năng mới, một cặp lập trình viên có thể thực hiện hàng tá các chu trình TDD này để tạo ra và hoàn thiện chức năng đó theo từng bước nhỏ cho đến khi không cần phải thêm hay bớt bất cứ gì vào chức năng đó. Nghiên cứu chỉ ra rằng TDD giúp giảm rất nhiều bug trong lập trình. Khi được dùng đúng cách, TDD còn giúp cải thiện thiết kế, tạo ra miêu tả về các interface và giúp tránh mắc lỗi có thể xảy ra trong tương lai.

TDD không hoàn hảo, tất nhiên! ( Có điều gì là hoàn hảo không nhỉ?) TDD khó dùng với những mã nguồn của người khác. Thậm chí ngay cả khi bạn tự viết mã nguồn của mình từ đầu, bạn vẫn cần vài tháng dùng TDD liên tục để có thể sử dụng thành thục TDD. Hãy thử dùng nó! Mặc dù bạn sẽ thực hành TDD tốt hơn nếu bạn biết một số kỹ thuật XP khác ( ví dụ: refactoring,…) nhưng nếu bạn không biết cũng chẳng sao. Bạn có thể dùng TDD trong gần như bất kỳ project nào.

Tại sao TDD có thể giúp chúng ta đáp ứng được những đòi hỏi khắt khe của lập trình ở trên.

Thời kỳ đầu của lập trình, khi những lập trình viên phải cần cù (mất rất nhiều thời gian) để kiểm tra (bằng tay thông qua punchcard) code (mã nguồn) của họ để đảm bảo code có thể compile được.  Một lỗi về compile có thể dẫn đến lỗi cả chương trình và phải debug rất kinh khủng chỉ để tìm ra một ký tự nào đó bị đặt nhầm chỗ.

Ngày nay, việc compile code  không còn là vấn đề to tát nữa. Gần như tất cả các IDE đều kiểm tra cú pháp (syntax) code ngay khi bạn gõ, và vài IDC thậm chí compile ngay sau mỗi lần bạn save (NetBeans là  1 ví dụ). Quá trình phản hồi rất nhanh nên rất dễ tìm lỗi và fix(sửa) lỗi.

Nếu có thứ gì đó không compile thì ta không cần phải kiểm tra lại code nhiều nữa (IDE đã tìm hộ ta rồi).

Test-driven development cũng áp dụng nguyên lý y hệt ở trên đối với ý định của lập trình viên. Cũng như những IDE hiện đại bây giờ tạo ra nhiều phản hồi về cú pháp code của bạn, TDD phản hồi về việc thực thi của đoạn code bạn viết. Cứ vài phút – thậm chí là sau mỗi 20,30 giây – TDD bảo đảm rằng code bạn viết làm đúng những gì bạn muốn nó làm. Như vậy, nếu có gì đó lỗi, bạn chỉ cần kiểm tra lại vài dòng code. Lỗi, bug rất dễ tìm và sửa.

TDD có cách làm tương tự như việc double-entry bookkeeping ( ghi sổ kế toán hai lần). Bạn diễn giải ý định của mình hai lần, theo hai cách: đầu tiên bằng một test, sau đó bằng production code. Khi chúng khớp với nhau, thì có vẻ là cả hai đều đúng. Nếu không thì tức là đã có lỗi ở đâu đó.

Ở trên mình nói “có vẻ cả hai đều đúng” là bởi vì: trên lý thuyết, có khả năng cả test code và production code của bạn sai giống nhau nên mặc dù chúng khớp nhau và khiến cho bạn nghĩ rằng cả hai đều đúng ( thực tế là cả hai đều sai). Thực tế thì, trừ phi bạn copy & paste code  giữa test code và production code, còn lại thì sẽ rất hiếm khi xảy ra trường hợp trên nên bạn không cần phải lo việc test pass nhưng production code sai.

Với TDD, các test được viết tương tự như interface của một class. Chúng tập trung vào hành vi (behavior) của class hơn là sự thực hiện chúng ( implementation). Lập trình viên  viết mỗi test code trước production code tương ứng. Điều này tập trung sự chú ý của họ vào tạo nên những interface dễ dùng hơn là dễ thực hiện ( implementation), khiến cho thiết kế của interface được cải thiện.

Sau quá trình TDD, các test code vẫn còn. Chúng sẽ kiểm tra toàn bộ production code, chúng đóng vai trò như là tài liệu sống của production code. Quan trọng hơn, lập trình viên chạy toàn bộ test mỗi lần build, đảm bảo rằng production code tiếp tục làm việc theo đúng ý muốn của họ. Nếu có lỗi gì đó thay đổi hành vi của production code ( ví du như refactor lỗi), các test code fail, chỉ ra lỗi đó.

Cách thực hành TDD

“Chìa khoá của TDD là small increments (những bước tiến nhỏ).”

TDD  là một trong những điều chỉ mất vài phút để học và mất cả đời để thành thục.

Các bước cơ bản của TDD rất dễ học, nhưng tư tưởng (mindset) thì cần thời gian để thấm nhuần. Cho đến khi bạn thực sự thành thục TDD và thấm nhuần tư tưởng của nó, TDD sẽ có vẻ là một cách làm chậm chạp, vụng về, kém hiệu quả. Bạn cần hai hoặc ba tháng hoàn toàn dùng TDD để có thể quen với nó.

            Cứ mỗi vài phút, TDD đưa cho ta những đoạn code đã được test, design cẩn thận.

Hãy tưởng tượng lập trình 1 phần mềm như  là việc cố gắng tiến lên trong lúc tắc đường thì  TDD là một cái xe máy nhỏ gọn và siêu nhanh ( chứ không phải là 1 cái ô tô 8 chỗ hoành tráng). Chiếc xe máy lạng lách đánh võng liên tục qua những chiếc ô tô để tiến lên phía trước tương tự như TDD lặp đi lặp lại những chu trình siêu ngắn để hoàn thiện phần mềm. Cứ mỗi vài phút, chu trình TDD giúp bạn tiến (lách) lên được 1 chút, giúp bạn viết được những đoạn code, mặc dù chưa hoàn thiện, nhưng đã được desgin, test và refactor cẩn thận, và sẵn sàng để đưa vào chương trình. Để dùng TDD, hãy làm theo chu trình “red, green, refactor” ( gọi là TDD cycle – chu trình TDD) vẽ dưới đây. Khi đã thành thục, trừ khi bạn refactor quá nhiều, mỗi chu trình chỉ mất ít hơn 5 phút. Bạn lặp đi lặp lại chu trình cho đến khi làm xong phần mềm. Cứ sau vài phút, bạn phải chắc chắn là tất cả test của bạn đã pass.

TDD cycle
TDD cycle

Figure. The TDD Cycle

Step 1: Think (Bước 1: Ngẫm)

TDD dùng những test nhỏ (unit test, từng test case) để buộc bạn phải viết production code để pass được những test đó, nhưng bạn chỉ được viết “vừa đủ” code để pass test thôi. XP nói rằng: “Đừng viết bất kỳ dòng production code nào khi bạn không có một test bị fail.” (production code bạn viết ra phải để pass 1 test nào đó). Như vậy, bước đầu tiên của TDD là (think) nghĩ theo một quy trình khá lạ lùng: Tưởng tượng hành vi mà bạn muốn production code của mình có, sau đó nghĩ về cách để thực hiện hành vi đó trong ít hơn 5 dòng code. Tiếp theo, nghĩ về một test ( cũng chỉ viết trong vài dòng code) mà sẽ fail trừ khi hành vi mà bạn nghĩ được thực hiện (đoạn code 5 dòng kia được thực hiện). Nói cách khác, nghĩ về một test (test case) mà ép bạn phải viết tiếp vài dòng production code. Đây là phần khó nhất của TDD bởi vì khái niệm test lèo lái code có vẻ là ngược đời, và điều này còn khó hơn nữa khi phải làm theo từng bước nhỏ. Pair programming có thể giúp điều này. Trong khi người driver viết code để pass được test hiện tại(đang fail) thì người navigator nên nghĩ trước vài bước, nghĩ về những test mà lái bạn đến những dòng production code tiếp theo.

Step 2: Red Bar

Xong step 1, bây giờ thì bạn viết test thôi. Bạn chỉ viết đủ test code cho “bước tiến” hiện tại ( current increment) của hành vi (behavior) – thường là ít hơn 5 dòng production code. Nếu nhiều hơn, cũng không sao, lần sau bạn nên cố gắng tiến 1 “bước tiến” nhỏ hơn. Viết test code  cho hành vi của class và interface của class đó chứ không phải cho sự thi hành ( implement) những cái bên trong class. Hãy tôn trọng sự bao gói (encapsulation). Trong những test đầu tiên, điều này thường dẫn đến việc bạn viết test code mà dùng những method và class mà chưa tồn tại. Điều là này chủ ý (của TDD), nó ép bạn design interface của class từ góc nhìn là một người dùng class chứ không phải là một người implement class. Sau khi viết xong test code, chạy toàn bộ test của bạn và xem test bạn vừa viết fail. Hầu hết TDD testing tool đều hiện thị 1 test fail bằng 1 red bar. Đây là cơ hôi đầu tiên của bạn để so sánh ý định của mình với điều gì thực sự đang diễn ra. Nếu test bạn mới viết không fail, hoặc nó fail không như bạn mong đợi, tức là có gì đó không đúng. Có thể test code của bạn sai dòng nào đó, hoặc nó không test điều mà bạn muốn test. Hãy làm cho test của bạn fail theo đúng cách bạn muốn, bạn luôn phải biết test code của bạn để test cái gì. Bởi vì những test này tồn tại đến tận lúc bạn viết xong chương trình và nó test toàn bộ chương trình của bạn. Bạn phải luôn biết  test code  test cái gì và test như thế nào.

Step 3: Green Bar

Tiếp theo, hãy viết “vừa đủ”   production code  để pass được test bạn mới viết. Nhắc lại lần nữa, bạn chỉ nên viết ít hơn 5 dòng code. Đừng lo lắng về design purity or conceptual elegance ( well, không biết là gì, chắc cao thủ mới biết 2 cái này), chỉ cần làm sao pass được test là được. Thi thoảng bạn phải thêm những đoạn hardcode vào (chẳng hạn for( i<10) chứ không phải for(i< array.size() ), không sao cả, vì bạn sẽ refactoring nó hay sau đó. Sau khi viết xong 5 dòng production code, chạy tất cả test và xem tất cả các test pass. Testing tool sẽ hiện thị 1 green bar. Đây là cơ hội thứ hai để bạn so sánh ý định của bạn với thực tế. Nếu test fail, bạn (hoặc người cùng pair programming) có thể nhanh chóng tìm ra lỗi sau khi xem lại đoạn production code mới viết.  Nếu không thấy vấn đề gì trong đoạn production code mới viết, bạn có thể xoá đoạn đó đi, viết production code mới theo hướng khác. Đôi khi, cách tốt nhất lại là xoá hẳn cái test đi và viết một cái test cho một “bước tiến” nhỏ hơn (chia nhỏ hơn nữa vấn đề bạn đang giải quyết). Kiểm soát được code của bạn là chìa khoá. Không vấn đề gì cho việc bạn lùi lại vài bước để kiểm soát được code. Nếu cần thiết, thống nhất với người cùng code (pair) với bạn là sau 5 hoặc 10 phút nếu không giải quyết được vấn đề thì sẽ Crt Z về đoạn code chạy tốt.

Step 4: Refactor

Sau khi bạn đã pass tất cả các test, bạn có thể refactor mà không lo lắng việc làm chương trình bị lỗi. Xem lại production code và cố gắng cải tiến nó. Hỏi lại ông navigator xem có ghi chú gì không. Mỗi lần refactor nên chỉ trong vòng 1 đến 2 phút (5 phút là tối đa), sau mỗi lần refactor phải ngay lập tức chạy lại tất cả test để đảm bảo  không test nào bị fail. Nếu có test bị fail và bạn không biết tại sao nó fail, Crt Z để trở về thời điểm code chạy ngon trước đó. Bạn có thể refactor bao nhiêu lần tuỳ thích, cải thiện design tốt hết mức bạn có thể, nhưng chỉ giới hạn trong những hành vi đã tồn tại của code. Trong khi refactor đừng nghĩ đến tương lai, và đừng thêm bất kì hành vi nào. Hãy nhớ, refactoring không làm thay đổi hành vi. Hành vi mới cần một test mới  và phải fail trước đã.

Step 5: Repeat: Khi bạn không còn gì để refactor, hãy bắt đầu TDD cycle với một hành vi mới.

Mỗi khi bận hoàn thành một TDD cycle, bạn thêm vào chương trình một chút code đã được desgin, test cẩn thận. Chìa khoá để thành công khi làm TDD là small increments(những bước tiến nhỏ). Thông thường, bạn sẽ làm liền tù tì vài TDD cycle trong thời gian ngắn, sau đó bạn mới dừng lại và bắt đầu thực hiện refactoring cho các cycle đó, thông thường là như vậy. Thực hành TDD nhiều, bạn có thể hoàn thành hơn 20 TDD cycle trong một tiếng. Dù vậy, đừng tập trung  vào vấn đề nhanh hay chậm khi làm TDD. Điều đó có thể khiến bạn bỏ qua việc refactoring và desgin, mà hai điều này là hai điều quan trọng nhất khi làm TDD. Để dành nhiều thời gian hơn cho refactoring và design, hãy làm TDD cycle theo từng bước rất nhỏ, chạy các test thường xuyên hơn, và tối thiểu thời gian bạn dùng để xử lý red bar.

A TDD Example

Dưới đây là ghi chép về cách tôi dùng TDD để giải quyết 1 ví dụ. Những bước làm (increment: ở trên mình dịch increment là “bước tiến”, vì ở trên là lý thuyết, còn ở đây là 1 ví dụ thực tế thì dịch là “bước làm” nghe xuôi hơn, bạn thích cách dịch nào hơn?) đều rất nhỏ (bạn có thể thấy buồn cười vì chúng quá nhỏ nhặt), nhưng chính điều này giúp tìm lỗi siêu dễ dàng và giúp tôi giải quyết vấn đề nhanh hơn. Những lập trình viên mới dùng TDD thường ngạc nhiên về việc sao những “bước làm” lại nhỏ đến vậy. Bạn có thể cho rằng chỉ người mới dùng TDD mới cần làm việc theo những bước nhỏ như vậy nhưng theo kinh nghiệm của tôi thì ngược lại mới đúng: càng làm nhiều TDD, bạn càng làm theo những bước nhỏ hơn và điều đó giúp bạn làm việc càng nhanh hơn. Khi đọc ví dụ này, bạn cần chú ý 1 điều là việc giải thích cho bạn hiểu mất nhiều thời gian hơn so với khi tôi chỉ làm mà không giải thích. Thực tế, nếu không giải thích, tôi làm xong mỗi bước ở dưới đây chỉ trong vài chục giây. Đề bài: Bạn cần viết 1 Java class để parse một HTTP query string. Bạn làm theo TDD. ( Hãy giả vờ chưa có ai giải quyết vấn đề này, bạn là người đầu tiên – bạn là một trong số những người viết ra ngôn ngữ Java và bạn được giao việc là xử lý vấn đề trên) Chú thích của người dịch: một HTTP query string có dạng ?name1=value1&name2=value2 ví dụ bạn gõ vào trình duyệt dòng sau: http://www.google.com.vn/search?q=tienlagi Query String mà google cần parse là ?q=tienlagi : q là name  , tienlagi là value Bắt đầu: (Trước khi bắt đầu, nếu đọc ví dụ này bạn không hiểu, hãy mở NetBeans hoặc bất cứ gì bạn dùng để lập trình Java ra và làm theo từng bước của ví dụ, bạn sẽ dễ hiểu hơn).

Một cặp ” name-value “

Step 1: Think. Bước đầu tiên là tưởng tượng ra chức năng bạn muốn  production code của mình thực hiện. Ý nghĩ đầu tiên của tôi là: “Java class của tôi sẽ tách cặp ” name-value ” vào một HashMap”. Nhưng như vậy thì sẽ cần hơn 5 dòng code, thế nên tôi cần phải nghĩ đến 1 bước làm nhỏ hơn. Thông thường, cách tốt nhất để có được một “bước làm” nhỏ đó là bắt đầu với những trường hợp  có vẻ tầm thường, đơn giản. “Java class của tôi sẽ cho một cặp “tên, giá trị” vào một HashMap”. Tôi nghĩ: trường hợp này có lẽ là đủ đơn giản để bắt đầu. Như vậy tôi sẽ bắt đầu với một cặp ” name-value ” Step 2: Red Bar.  Bước tiếp theo là viết test. Hãy nhớ, một phần việc phải làm khi dùng TDD là design interface. Trong ví dụ này, ý tưởng đầu tiên của tôi là gọi đến class tên QueryStringParser, nhưng điều đó không được hướng đối tượng lắm. Vì vậy tôi đặt tên class là QueryString. Khi tôi viết test, tôi nhận ra rằng một class mà tên là QueryString thì sẽ không trả về một HashMap; nó sẽ encapsulate (bao gói) HashMap. Nó sẽ có một method ví dụ valueFor(name) để truy cập vào cặp  name-value. Bạn hãy để ý rằng: việc nghĩ về test đã ép tôi hình dung ra cách tôi muốn thiết kế chương trình của mình (design my code). Building that seemed like it would require too much code for one increment, so I decided to have this test to drive the creation of a count() method instead. I decided that the count() method would return the total number of name/value pairs. My test checked that it would work when there was just one pair.

  public void testOneNameValuePair() {
      QueryString qs = new QueryString("name=value");
      assertEquals(1, qs.count());
  }

The code didn’t compile, so I wrote a do-nothing QueryString class and count() method.

  public class QueryString {
      public QueryString(String queryString) {}
      public int count() { return 0; }
  }

That gave me the red bar I expected. Green Bar. To make this test pass, I hardcoded the right answer. I could have programmed a better solution, but I wasn’t sure how I wanted the code to work. Would it count the number of equal signs? Not all query strings have equal signs. I decided to punt.

  public int count() { return 1; }

Green bar. Although I had ideas about how I would finish writing this class, I didn’t commit myself to any particular course of action in advance. I remained open to discovering new approaches as I worked. Step 4: Refactor. I didn’t like the QueryString name, but I had another test in mind and I was eager to get to it. I made a note to fix the name on an index card—perhaps HttpQuery would be better. I’d see how I felt next time through the cycle. Step 5: Repeat. Yup.

An empty string

Think. I wanted to force myself to get rid of that hard-coded return 1, but I didn’t want to have to deal with multiple query strings yet. My next increment would probably be about the valueFor() method, and I wanted to avoid the complexity of multiple queries. I decided that testing an empty string would require me to code count() properly without making future increments too difficult. Red Bar. New test.

  public void testNoNameValuePairs() {
      QueryString qs = new QueryString("");
      assertEquals(0, qs.count());
  }

Red bar. Expected: <0> but was: <1>. No surprise there. This inspired two thoughts. First, I should test the case of a null argument to the QueryString constructor. Second, I was starting to see duplication in my tests that needed refactoring. I added both notes to my index card. Green Bar. Now I had to stop and think. What was the fastest way for me to get back to a green bar? I decided to check if the query string was blank.

  public class QueryString {
      private String _query

      public QueryString(string queryString) {
          _query = queryString;
      }

      public int count() {
          if ("".equals(_query)) return 0;
          else return 1;
      }
  }

Refactor. I doublechecked my to-do list. I needed to refactor the tests, but I decided to wait for another test to demonstrate the need. “Three strikes and you refactor,” as the saying goes. It was time to do the cycle again.

testNull()

Think. My list included testNull(), which meant I needed to test the case when the query string is null. I decided to put that in. Red Bar. This test forced me to think about what behavior I wanted when the value was null. I’ve always been a fan of code that fails fast, so I decided that a null value was illegal. This meant the code should throw an exception telling callers not to use null values. (Simple Design, later in this chapter, discusses failing fast in detail.)

    public void testNull() {
        try {
            QueryString qs = new QueryString(null);
            fail("Should throw exception");
        }
        catch (NullPointerException e) {
            // expected
        }
    }

Green Bar. Piece of cake.

    public QueryString(String queryString) {
        if (queryString == null) throw new NullPointerException();

        _query = queryString;
    }

Refactor. I still needed to refactor my tests, but the new test didn’t have enough in common with the old tests to make me feel it was necessary. The production code looked okay, too, and there wasn’t anything significant on my index card. No refactorings this time. Although I don’t refactor on every cycle, I always stop and seriously consider whether my design needs refactoring.

valueFor()

Think. Okay, now what? The easy tests were done. I decided to put some meat on the class and implement the valueFor() method. Given a name of a name/value pair in the query string, this method would return the associated value. As I thought about this test, I realized I also needed a test to show what happens when the name doesn’t exist. I wrote that on my index card for later. Red Bar. To make the tests fail, I added a new assertion at the end of my existing testOneNameValuePair() test.

  public void testOneNameValuePair() {
      QueryString qs = new QueryString("name=value");
      assertEquals(1, qs.count());
      assertEquals("value", qs.valueFor("name"));
  }

Green Bar. This test made me think for a moment. Could the split() method work? I thought it would.

  public String valueFor(String name) {
      String[] nameAndValue = _query.split("=");
      return nameAndValue[1];
  }

This code passed the tests, but it was incomplete. What if there were more than one equal sign, or no equal signs? It needed proper error handling. I made a note to add tests for those scenarios. Refactor. The names were bugging me. I decided that QueryString was okay, if not perfect. The qs in the tests was sloppy, so I renamed it query.

Multiple name/value pairs

Think. I had a note reminding me to take care of error handling in valueFor(), but I wanted to tackle something more meaty. I decided to add a test for multiple name/value pairs. Red Bar. When dealing with a variable number of items, I usually test the case of zero items, one item, and three items. I had already tested zero and one, so now I tested three.

  public void testMultipleNameValuePairs() {
      QueryString query = new QueryString("name1=value1&name2=value2&name3=value3");
      assertEquals("value1", query.valueFor("name1"));
      assertEquals("value2", query.valueFor("name2"));
      assertEquals("value3", query.valueFor("name3"));
  }

I could have written an assertion for count() rather than valueFor(), but the real meat was in the valueFor() method. I made a note to test count() next. Green Bar. My initial thought was that the split() technique would work again.

  public String valueFor(String name) {
      String[] pairs = _query.split("&");
      for (String pair : pairs) {
          String[] nameAndValue = pair.split("=");
          if (nameAndValue[0].equals(name)) return nameAndValue[1];
      }
      throw new RuntimeException(name + " not found");
  }

Ewww… that felt like a hack—but the test passed! It’s better to get to a green bar quickly than to try for perfect code. A green bar keeps you in control of your code and allows you to experiment with refactorings that clean up your hack. Refactor: The additions to valueFor() felt hackish. I took a second look. The two issues that bothered me the most were the nameAndValue array and the RuntimeException. An attempt to refactor nameAndValue led to worse code, so I backed out the refactoring and decided to leave it alone for another cycle. The RuntimeException was worse; it’s better to throw a custom exception. In this case, though, the Java convention is to return null rather than throw an exception. I already had a note that I should test the case where name isn’t found; I revised it to say that the correct behavior was to return null. Reviewing further, I saw that my duplicated test logic had reached three duplications. Time to refactor… or so I thought. After a second look, I realized that the only duplication between the tests was that I was constructing a QueryString object each time. Everything else was different, including QueryString‘s constructor parameters. The tests didn’t need refactoring after all. I scratched that note off of my list. In fact, the code was looking pretty good… better than I initially thought, at least. I’m too hard on myself.

Multiple count()

Think. After reviewing my notes, I realized that I should probably test degenerate uses of the ampersand, such as two ampersands in a row. I made a note to add tests for that. At the time, though, I wanted to get the count() method working properly with multiple name/value pairs. Red Bar. I added the count() assertion to my test. It failed as expected.

  public void testMultipleNameValuePairs() {
      QueryString query = new QueryString("name1=value1&name2=value2&name3=value3");
      assertEquals(3, query.count());
      assertEquals("value1", query.valueFor("name1"));
      assertEquals("value2", query.valueFor("name2"));
      assertEquals("value3", query.valueFor("name3"));
  }

Green Bar. To get this test to pass, I stole my existing code from valueFor() and modified it. This was blatant duplication, but I planned to refactor as soon as I saw the green bar.

  public int count() {
      String[] pairs = _query.split("&");
      return pairs.length;
  }

I was able to delete more of the copied code than I expected. To my surprise, however, it didn’t pass! The test failed in the case of an empty query string: expected: <0> but was: <1>. I had forgotten that split() returned the original string when the split character isn’t found. My code expected it to return an empty array when no split occurred. I added a guard clause that took care of the problem. It felt like a hack, so I planned to take a closer look after the tests passed.

  public int count() {
      if ("".equals(_query)) return 0;
      String[] pairs = _query.split("&");
      return pairs.length;
  }

Refactor. This time I definitely needed to refactor. The duplication between count() and valueFor() wasn’t too strong—it was just one line—but they both parsed the query string, which was a duplication of function if not code. I decided to fix it. At first, I wasn’t sure how to fix the problem. I decided to try to parse the query string into a HashMap, as I had originally considered. To keep the refactoring small, I left count() alone at first and just modified valueFor(). It was a small change.

  public String valueFor(String name) {
      HashMap<String, String> map = new HashMap<String, String>();

      String[] pairs = _query.split("&");
      for (String pair : pairs) {
          String[] nameAndValue = pair.split("=");
          map.put(nameAndValue[0], nameAndValue[1]);
      }
      return map.get(name);
  }

This refactoring eliminated the exception that I threw when name wasn’t found. Technically, it changed the behavior of the program. However, because I hadn’t yet written a test for that behavior, I didn’t care. I made sure I had a note to test that case later (I did) and kept going. This code parsed the query string during every call to valueFor(), which wasn’t a great idea. I had kept the code in valueFor() to keep the refactoring simple. Now I wanted to move it out of valueFor() into the constructor. This required a sequence of refactorings, described in Refactoring later in this chapter. I reran the tests after each of these refactorings to make sure that I hadn’t broken anything… and in fact, one refactoring did break the tests. When I called the parser from the constructor, testNoNameValuePairs()—the empty query test—bit me again, causing an exception in the parser. I added a guard clause as before, which solved the problem. After all that refactoring, the tests and production code were nice and clean.

  public class QueryStringTest extends TestCase {
      public void testOneNameValuePair() {
          QueryString query = new QueryString("name=value");
          assertEquals(1, query.count());
          assertEquals("value", query.valueFor("name"));
      }

      public void testMultipleNameValuePairs() {
          QueryString query = new QueryString("name1=value1&name2=value2&name3=value3");
          assertEquals(3, query.count());
          assertEquals("value1", query.valueFor("name1"));
          assertEquals("value2", query.valueFor("name2"));
          assertEquals("value3", query.valueFor("name3"));
      }

      public void testNoNameValuePairs() {
          QueryString query = new QueryString("");
          assertEquals(0, query.count());
      }

      public void testNull() {
          try {
              QueryString query = new QueryString(null);
              fail("Should throw exception");
          }
          catch (NullPointerException e) {
              // expected
          }
      }
  }

  public class QueryString {
      private HashMap<String, String> _map = new HashMap<String, String>();

      public QueryString(String queryString) {
          if (queryString == null) throw new NullPointerException();
          parseQueryString(queryString);
      }

      public int count() {
          return _map.size();
      }

      public String valueFor(String name) {
          return _map.get(name);
      }

      private void parseQueryString(String query) {
          if ("".equals(query)) return;

          String[] pairs = query.split("&");
          for (String pair : pairs) {
              String[] nameAndValue = pair.split("=");
              _map.put(nameAndValue[0], nameAndValue[1]);
          }
      }
  }

Your turn

The class wasn’t done—it still needed to handle degenerate uses of the equals and ampersand signs, and it didn’t fully implement the query string specification yet, either2. In the interest of space, though, I leave the remaining behavior as an exercise for you to complete yourself. As you try it, remember to take very small steps and to check for refactorings every time through the cycle. 2For example, the semicolon works like the ampersand in query strings.

Testing Tools

In order to use TDD, you need a testing framework. The most popular are the open-source xUnit tools, such as JUnit (for Java) and NUnit (for .NET). Although these tools have different authors, they typically share the basic philosophy of Kent Beck’s pioneering SUnit. Instructions for specific tools are out of the scope of this book. Introductory guides for each tool are easily found online. If your platform doesn’t have an xUnit tool, you can build your own. Although the existing tools often provide GUIs and other fancy features, none of that is necessary. All you need is a way to run all of your test methods as a single suite, a few assert methods, and an unambiguous pass or fail result when the test suite is done.

Speed Matters

As with programming itself, TDD has myriad nuances to master. The good news is that the basic steps alone—red, green, refactor—will lead to very good results. Over time, you’ll fine-tune your approach. One of the nuances of TDD is test speed—not the frequency of each increment, which is also important, but how long it takes to run all of the tests. In TDD, you run the tests as often as one or two times every minute. They must be fast. If they aren’t, they’ll be a distraction rather than a help. You won’t run them as frequently, which reduces the primary benefit of TDD: micro-increments of proof. [Nielsen] reports that users lose interest and switch tasks when the computer makes them wait more than ten seconds. Computers only seem “fast” when they make users wait less than a second. Make sure your tests take under ten seconds to run. Although this research explored the area of user interface design, I’ve found it to be true when running tests as well. If they take more than ten seconds, I’m tempted to check my email, surf the web, or otherwise distract myself. Then it takes several minutes for me to get back to work. To avoid this, make sure your tests take under ten seconds to run. Less than a second is even better. An easy way to keep your test times down is to run a subset of tests during the TDD cycle. Periodically run the whole test suite to make sure you haven’t accidentally broken something, particularly before integrating and during refactorings that affect multiple classes. Running a subset does incur the risk that you’ll make a mistake without realizing it, leading to annoying debugging problems later. Advanced practitioners design their tests to run quickly. This requires that they make trade-offs between three basic types of automated tests:

  • Unit tests, which run at a rate of hundreds per second
  • Focused integration tests, which run at a rate of a handful per second
  • End-to-end tests, which often require seconds per test

The vast majority of your tests should be unit tests. A small fraction should be integration tests, and only a handful should be end-to-end tests.

Unit Tests

Unit tests focus just on the class or method at hand. They run entirely in memory, which makes them very fast. Depending on your platform, your testing tool should be able to run at least 100 unit tests per second. [Feathers] provides an excellent definition of a unit test: Unit tests run fast. If they don’t run fast, they aren’t unit tests. Other kinds of tests often masquerade as unit tests. A test is not a unit test if:

  1. It talks to a database
  2. It communicates across a network
  3. It touches the file system
  4. You have to do special things to your environment (such as editing configuration files) to run it

Tests that do these things are integration tests, not unit tests. Creating unit tests requires good design. A highly-coupled system—a big ball of mud, or spaghetti software—makes it difficult to write unit tests. If you have trouble doing this, or if Feathers’ definition seems impossibly idealistic, it’s a sign of problems in your design. Look for ways to decouple your code so that each class, or set of related classes, may be tested in isolation. See Simple Design later in this chapter for ideas, and consider asking your mentor for help.

Mock Objects

Mock objects are a popular tool for isolating classes for unit testing. When using mock objects, your test substitutes its own object (the “mock object”) for an object that talks to the outside world. The mock object checks that it is called correctly and provides a pre-scripted response. In doing so, it avoids time-consuming communication to a database, network socket, or other outside entity. Beware of mock objects. They add complexity and tie your test to the implementation of your code. When you’re tempted to use a mock object, ask yourself if there’s a way you could improve the design of your code so that a mock object isn’t necessary. Can you decouple your code from the external dependency more cleanly? Can you provide the data it needs—in the constructor, perhaps—rather than having it go get the data itself? Mock objects are a useful technique. Sometimes they’re the best way to test your code. Before you assume that a mock object is appropriate for your situation, however, take a second look at your design. You might have an opportunity for improvement.

Focused Integration Tests

Unit tests aren’t enough. At some point, your code has to talk to the outside world. You can use TDD for that code, too. A test that causes your code to talk to a database, communicate across the network, touch the file system, or otherwise leave the bounds of its own process is an integration test. The best integration tests are focused integration tests that test just one interaction with the outside world. You may think of an integration test as a test that checks that the whole system fits together properly. I call that kind of test an end-to-end test. One of the challenges of integration tests is the need to prepare the external dependency to be tested. Tests should run exactly the same way every time, regardless of which order you run them in or the state of the machine prior to running them. This is easy with unit tests but harder with integration tests. If you’re testing your ability to select data from a database table, that data needs to be in the database. Make sure each test is isolated from the others. Make sure each integration test can run entirely on its own. It should set up the environment it needs and then restore the previous environment afterwards. Be sure to do so even if the test fails or an exception is thrown. Nothing is more frustrating than a test that intermittently fails. Integration tests that don’t set up and tear down their test environment properly are common culprits. If you have a test that fails intermittently, don’t ignore it, even if you can “fix” the failure by running the tests twice in a row. Intermittent failures are an example of technical debt. They make your tests more frustrating to run and disguise real failures. You shouldn’t need many integration tests. The best integration tests have a tight focus; each checks just one aspect of your program’s ability to talk to the outside world. The number of focused integration tests in your test suite should be proportional to the types of external interactions your program has, not the overall size of the program. (In contrast, the number of unit tests you have is proportional to the overall size of the program.) If you need a lot of integration tests, it’s a sign of design problems. It may mean that the code that talks to the outside world isn’t cohesive. For example, if all your business objects talk directly to a database, you’ll need integration tests for each one. A better design would be to have just one class that talks to the database. The business objects would talk to that class.3 In this scenario, only the database class would need integration tests. The business objects could use ordinary unit tests. 3A still better design might involve a persistence layer.

End-to-End Tests

In a perfect world, the unit tests and focused integration tests mesh perfectly to give you total confidence in your tests and code. You should be able to make any changes you want without fear, comfortable in the knowledge that if you make a mistake, your tests will catch them. How can you be sure that your unit tests and integration tests mesh perfectly? One way is to write end-to-end tests. End-to-end tests exercise large swaths of the system, starting at (or just behind) the user interface, passing through the business layer, touching the database, and returning. Acceptance tests and functional tests are common examples of end-to-end tests. Some people also call them integration tests, although I reserve that term for focused integration tests. End-to-end tests can give you more confidence in your code, but they suffer from many problems. They’re difficult to create because they require error-prone and labor-intensive setup and teardown procedures. They’re brittle and tend to break whenever any part of the system or its setup data changes. They’re very slow—they run in seconds or even minutes per test, rather than multiple tests per second. They provide a false sense of security, by exercising so many branches in the code that it’s difficult to say which parts of the code are actually covered. Ally Exploratory Testing Instead of end-to-end tests, use exploratory testing to check the effectiveness of your unit and integration tests. When your exploratory tests find a problem, use that information to improve your approach to unit and integration testing, rather than introducing end-to-end tests. Don’t use exploratory testing to find bugs; use it to determine if your unit tests and integration tests mesh properly. When you find an issue, improve your TDD strategy. In some cases, limitations in your design may prevent unit and integration tests from testing your code sufficiently. This is particularly true when you have legacy code. In that case, end-to-end tests are a necessary evil. Think of them as technical debt: strive to make them unnecessary, and replace them with unit and integration tests whenever you have the opportunity.

TDD and Legacy Code

[Feathers] says legacy code is “code without tests.” I think of it as “code you’re afraid to change.” This is usually the same thing. The challenge of legacy code is that, because it was created without tests, it usually isn’t designed for testability. In order to introduce tests, you need to change the code. In order to change the code with confidence, you need to introduce tests. It’s this kind of chicken-and-egg problem that makes legacy code so difficult to work with. To make matters worse, legacy code has often accumulated a lot of technical debt. (It’s hard to remove technical debt when you’re afraid to change existing code.) You may have trouble understanding how everything fits together. Methods and functions may have side effects that aren’t apparent. One way to approach this problem is to introduce end-to-end smoke tests. These tests exercise common usage scenarios involving the component you want to change. They aren’t sufficient to give you total confidence in your changes, but they at least alert you when you make a big mistake. With the smoke tests in place, you can start introducing unit tests. The challenge here is finding isolated components to test, as legacy code is often tightly coupled code. Instead, look for ways for your test to strategically interrupt program execution. [Feathers] calls these opportunities seams. For example, in an object-oriented language, if a method has a dependency you want to avoid, your test can call a test-specific subclass that overrides and stubs out the offending method. Finding and exploiting seams often leads to ugly code. It’s a case of temporarily making the code worse so you can then make it better. Once you’ve introduced tests, refactor the code to make it test-friendly, then improve your tests so they aren’t so ugly. Then you can proceed with normal TDD. Adding tests to legacy code is a complex subject that deserves its own book. Fortunately, [Feathers]’ Working Effectively with Legacy Code is exactly that book.

Questions

What do I need to test when using TDD?

The saying is, “Test everything that can possibly break.” To determine if something could possibly break, I think, “Do I have absolute confidence that I’m doing this correctly, and that nobody in the future will inadvertently break this code?” I’ve learned through painful experience that I can break nearly anything, so I test nearly everything. The only exception is code without any logic, such as simple accessors and mutators (getters and setters), or a method that only calls another method. You don’t need to test third-party code unless you have some reason to distrust it.

How do I test private methods?

As in my extended QueryString example, start by testing public methods. As you refactor, some of that code will move into private methods, but the existing tests will still thoroughly test its behavior. If your code is so complex that you need to test a private method directly, this may be a sign to refactor. You may benefit from moving the private methods into their own class and providing a public interface. The “Replace Method with Method Object” refactoring [Fowler 1999] (p. 135) may help.

How can I use TDD when developing a user interface?

TDD is particularly difficult with user interfaces because most UI frameworks weren’t designed with testability in mind. Many people compromise by writing a very thin, untested translation layer that only forwards UI calls to a presentation layer. They keep all of their UI logic in the presentation layer and use TDD on that layer as normal. There are some tools that allow you to test a UI directly, perhaps by making HTTP calls (for web-based software), or by pressing buttons or simulating window events (for client-based software). These are essentially integration tests and they suffer similar speed and maintainability challenges as other integration tests. Despite the challenges, these tools can be helpful.

You talked about refactoring your test code. Does anyone really do this?

Yes. I do, and everybody should. Tests are just code. The normal rules of good development apply: avoid duplication, choose good names, factor and design well. I’ve seen otherwise-fine projects go off of the rails because of brittle and fragile test suites. By making TDD a central facet of development, you’ve committed to maintaining your test code just as much as you’ve committed to maintaining the rest of the code. Take it just as seriously.

Results

When you use TDD properly, you find that you spend little time debugging. Although you continue to make mistakes, you find those mistakes very quickly and have little difficulty fixing them. You have total confidence that the whole codebase is well-tested, which allows you to aggressively refactor at every opportunity, confident in the knowledge that the tests will catch any mistakes.

Contraindications

Although TDD is a very valuable tool, it does have a two-or-three month learning curve. It’s easy to apply to toy problems such as the QueryString example, but translating that experience to larger systems takes time. Legacy code, proper unit test isolation, and integration tests are particularly difficult to master. On the other hand, the sooner you start using TDD, the sooner you’ll figure it out, so don’t let these challenges stop you. Be careful when applying TDD without permission. Learning TDD could slow you down temporarily. This could backfire and cause your organization to reject TDD without proper consideration. I’ve found that combining testing time with development time when providing estimates helps alleviate pushback for dedicated developer testing. Also be cautious about being the only one to use TDD on your team. You may find that your teammates break your tests and don’t fix them. It’s better to get the whole team to agree to try it together.

Alternatives

TDD is the heart of XP’s programming practices. Without it, all of XP’s other technical practices will be much harder to use. A common misinterpretation of TDD is to design your entire class first, then write all of its test methods, then write the code. This approach is frustrating and slow, and it doesn’t allow you to learn as you go. Another misguided approach is to write your tests after you write your production code. This is very difficult to do well—production code must be designed for testability, and it’s hard to do so unless you write the tests first. It doesn’t help that writing tests after the fact is boring. In practice, the temptation to move on to the next task usually overwhelms the desire for well-tested code. Although you can use these alternatives to introduce tests to your code, TDD isn’t just about testing. It’s really about using very small increments to produce high-quality, known-good code. I’m not aware of any alternatives that provide TDD’s ability to catch and fix mistakes quickly.

Further Reading

Test-driven development is one of the most heavily-explored aspects of Extreme Programming. There are several excellent books on various aspects of TDD. Most are focused on Java and JUnit, but their ideas are applicable to other languages as well. Test-Driven Development: By Example [Beck 2002] is a good introduction to TDD. If you liked the QueryString example, you’ll like the extended examples in this book. The TDD patterns in Part III are particularly good. Test-Driven Development: A Practical Guide [Astels] provides a larger example that covers a complete project. Reviewers praise its inclusion of UI testing. JUnit Recipes [Rainsberger] is a comprehensive book discussing a wide variety of testing problems, including a thorough discussion of testing J2EE. Working Effectively with Legacy Code [Feathers] is a must-have for anybody working with legacy code.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s