Tag

Hendry Lux menulis sebuah artikel yang bagus tapi panjang di milis dotnet. Berikut ini adalah kopas sari tulisan tersebut.

Hendry mengatakan TDD tidaklah cukup, perlu pendekatan BDD. Apa itu TDD bisa anda bisa tulisan saya di TDD Guide.

SpecUnit’s’s context == nbehave’s when dan given.

NBehave tackle BDD dari different angle. Rootnya agak berbeda dari xunit patterns.

Gw personally lebih into BDD framework kayak SpecUnit ato MSpec. Yang gw refer di podcast adalah SpecUnit… (yang incorrectly gw refer sebagai NBehave).
Both SpecUnit maupun MSpec adalah refinement dari xunit patterns. (e.g. Four-phase test). Classes di SpecUnit directly inherits dari nUnit.

Salah satu convention (anti-pattern?) di xunit tests adalah 1 test-class per SUT class/method. Ini biasanya ngelead ke test yang gak effective. BDD menekankan 1 test-class per context.

You made a good point bahwa most examples cuma highlight the end-result, tanpa ngejabarin journeynya. Gw coba cover disini the journey to get the end-result… (caution: prepare for long read). (Bagian yang berubah di tiap step, gw highlight).
Journey ini bisa illustrate slight difference antara conventional TDD dan BDD style.

Let’s say, for discussion sake, barang yang masuk ke shopping-cart bakal di-acquire dari inventory-system.
ShoppingCartTest mungkin terlalu obviously baaaad… Jadi mari bikin test yang “reasonably bad”, tapi sering banget orang2 tulis while doing TDD. Lalu kita coba refactor ke BDD style.

Test Driven Development
Gw mulai dari test pertama:
[TestFixture]
public class AddingProductToShoppingCartTest
{
[Test] public void ShouldContainAddedProductWithCorrectQuantity()
{
cart = new ShoppingCart(
this.customer = new Customer(),
this.inventoryService = mocks.Mock<InventoryService>());
nokia = new MobileHandset(42342);
customer.OrderHistory.Add(createPreviousOrder());

inventoryService.Expect(x=>x.AcquireProduct(nokia, Args.AnyInt)).ThenReturn(true);

cart.Add(nokia, 12);

cart.GetLineFor(nokia).ShouldNotBeNull();
cart.GetLineFor(nokia).Quantity.ShouldBe(20);
}
}

OK, sekarang test kedua: ShouldContainAddedProductWithCorrectQuantity(). Lo realize bahwa beberapa portion of the code bisa direuse… Jadi lo taro di SetUp method.
Btw ada 1 hal disini yang biasanya lo gak observe merely dari artikel yang highlight end-result (tanpa progress). Disini lo liat bahwa TDD SetUp method sifatnya “reactive”. Lo tulis SetUp method setelah couple of test-codes. Setup method itu meaningless, tujuannya cuma buat eliminate repetition dengan mengconsolidate the lest-common-denominator dari semua test-cases.
Lo bakal liat belakangan bahwa di BDD, SetUp method has its own place sebagai first-class citizen. Lo nulis SetUp method (aka Context) proactively sebelom lo nulis test apapun.

Anyway, ini hasil 2 test pertama kita setelah refactoring:

[TestFixture]
public class AddingProductToShoppingCartTest
{
[SetUp] public void BeforeEachTest()
{
cart = new ShoppingCart(
this.customer = new Customer(),
this.inventoryService = mocks.Mock<InventoryService>());
nokia = new MobileHandset(42342);
customer.OrderHistory.Add(createPreviousOrder());

inventoryService.Expect(x=>x.AcquireProduct(nokia, Args.AnyInt)).ThenReturn(true);
}

[Test] public void ShouldContainAddedProductWithCorrectQuantity()
{
cart.Add(nokia, 12);
cart.GetLineFor(nokia).ShouldNotBeNull();
cart.GetLineFor(nokia).Quantity.ShouldBe(12);
}

[Test] public void AddingSameProductsShouldGroupThemTogether()
{
cart.Add(nokia, 10);
cart.Add(nokia, 20);
cart.GetLineFor(nokia).ShouldNotBeNull();
cart.GetLineFor(nokia).Quantity.ShouldBe(30);
}
}

Oh so far so good… Lo nulis sekian 8-9 test-cases, sampe akhirnya lo sampe ke test-case berikutnya: CustomerFirstTimeOrder_ShouldAllowOnly1Product
Damn! SetUp method lo gak cocok buat scenario ini. Lo mesti refactor balik sebagian setup code lo balik ke tiap test-case.

[TestFixture]
public class AddingProductToShoppingCartTest
{
[SetUp] public void BeforeEachTest()
{
cart = new ShoppingCart(
this.customer = new Customer(),
this.inventoryService = mocks.Mock<InventoryService>());
nokia = new MobileHandset(42342);

inventoryService.Expect(x=>x.AcquireProduct(nokia, Args.AnyInt)).ThenReturn(true);
}

[Test] public void ShouldContainAddedProductWithCorrectQuantity()
{
customer.OrderHistory.Add(createPreviousOrder());

cart.Add(nokia, 12);
cart.GetLineFor(nokia).ShouldNotBeNull();
cart.GetLineFor(nokia).Quantity.ShouldBe(12);
}

[Test] public void AddingSameProductsShouldGroupThemTogether()
{
customer.OrderHistory.Add(createPreviousOrder());

cart.Add(nokia, 10);
cart.Add(nokia, 20);
cart.GetLineFor(nokia).ShouldNotBeNull();
cart.GetLineFor(nokia).Quantity.ShouldBe(30);
}

[Test] public void CustomerFirstTimeOrder_ShouldAllowNotAllowMoreThan1Product()
{
Assert.Throws<IllegalRequest>( ()=> cart.Add(nokia, 2));
cart.GetLineFor(nokia).ShouldBeNull();
}

[Test] public void CustomerFirstTimeOrder_ShouldAllow1Product()
{
cart.Add(nokia, 1);
cart.GetLineFor(nokia).ShouldNotBeNull();
cart.GetLineFor(nokia).Quantity.ShouldBe(1);
}
}

Gw ulangin lagi, SetUp method mengconsolidate the least-common-denominator dari semua test-cases. Setelah lo cover berbagai scenario dari test lo, soon enough lo gak bakal punya too much common-denominator. Lo bakal find yourself ngeganti banyak test-cases dan refactor test-code lo setiap lo nambahin 1 testcase baru yang ngecover scenario berbeda.
OK, nambahin lagi 1 test-case lagi: WhenInventoryRunsOutOfStock_ShouldRejectProduct(). Alas, lagi2 refactor the setup method. Jadi ini hasil akhir dari perjalanan TDD kita.

[TestFixture]
public class AddingProductToShoppingCartTest
{
[SetUp] public void BeforeEachTest()
{
cart = new ShoppingCart(
this.customer = new Customer(),
this.inventoryService = mocks.Mock<InventoryService>());
nokia = new MobileHandset(42342);
}

[Test] public void ShouldContainAddedProductWithCorrectQuantity()
{
customer.OrderHistory.Add(createPreviousOrder());
inventoryService.Expect(x=>x.AcquireProduct(nokia, 12)).ThenReturn(true);

cart.Add(nokia, 12);
cart.GetLineFor(nokia).ShouldNotBeNull();
cart.GetLineFor(nokia).Quantity.ShouldBe(12);
}

[Test] public void AddingSameProductsShouldGroupThemTogether()
{
customer.OrderHistory.Add(createPreviousOrder());
inventoryService.Expect(x=>x.AcquireProduct(nokia, 10)).ThenReturn(true);
inventoryService.Expect(x=>x.AcquireProduct(nokia, 20)).ThenReturn(true);

cart.Add(nokia, 10);
cart.Add(nokia, 20);
cart.GetLineFor(nokia).ShouldNotBeNull();
cart.GetLineFor(nokia).Quantity.ShouldBe(30);
}

[Test] public void CustomerFirstTimeOrder_ShouldAllowOnly1Product()
{
inventoryService.Expect(x=>x.AcquireProduct(nokia, Args.AnyInt)).ThenReturn(true);

Assert.Throws<IllegalRequest>( ()=> cart.Add(nokia, 2));
cart.GetLineFor(nokia).ShouldBeNull();
}

[Test] public void WhenInventoryRunsOutOfStock_ShouldRejectProduct()
{
customer.OrderHistory.Add(createPreviousOrder());
inventoryService.Expect(x=>x.AcquireProduct(nokia, 20)).ThenReturn(false);

Assert.Throws<IllegalRequest>(()=> cart.Add(nokia, 20));
cart.GetLineFor(nokia).ShouldBeNull();
}
}

Tiap cover test-case baru, lo ngerubah common-denomninator dari test-methods lo, dan lo jadi sibuk refactor terus. Setelah 99 test-cases, you have almost nothing in common. Setup method lo hampir gak ada isinya. Setiap test-case lo penuh repetition (arrange, expect, action, assert.. semua dalam tiap single method). Effort buat maintain test-code jadi uncontrollable. Test-code juga jadi unreadable. Coba lo baca tiap test-case di atas. Apa masih bisa baca dengan jelas what’s going on? Bisa? Coba balik lagi besok pagi dan baca lagi… tiba2 lo ngerasa lightheaded.

Behavior Driven Development
Coba gw translate the exact same test-cases menjadi SpecUnit syntax… dan switch mock-framework gw pake AAA syntax… (as opposed to record-replay)

public class when_adding_a_product:
Behaves_like_an_empty_shopping_cart_and_a_return_customer
{
public override void Because()
{
cart.Add(nokia, 12);
}

[Observation]
public void should_acquire_product_from_inventory()
{
inventoryService.AssertWasCalled(x=>x.AcquireProduct(nokia, 12));
}

[Observation]
public void cart_should_only_contain_1_line()
{
cart.Lines.ShouldHaveSize(1);
}

[Observation]
public void cart_should_contain_line_for_added_product()
{
cart.Lines.First().Product.ShouldBe(nokia);
}

[Observation]
public void cart_item_should_have_added_quantity()
{
cart.Lines.First().Quantity.ShouldBe(12);
}
}

public class when_adding_same_product_twice:
Behaves_like_an_empty_shopping_cart_and_a_return_customer
{
public override void Because()
{
cart.Add(nokia, 10);
cart.Add(nokia, 20);
}

[Observation]
public void should_acquire_both_requested_products_from_inventory()
{
inventoryService.AssertWasCalled(x=>x.AcquireProduct(nokia, 10));
inventoryService.AssertWasCalled(x=>x.AcquireProduct(nokia, 20));
}

[Observation]
public void cart_should_only_contain_1_line()
{
cart.Lines.ShouldHaveSize(1);
}

[Observation]
public void cart_should_contain_line_for_added_product()
{
cart.Lines.First().Product.ShouldBe(nokia);
}

[Observation]
public void cart_item_quantity_should_be_sum_of_added_quantities()
{
cart.Lines.First().Quantity.ShouldBe(10 + 20);
}
}

public class when_inventory_runs_out_of_stock:
Behaves_like_an_empty_shopping_cart_and_a_return_customer
{
Exception exception;
public override void Because()
{
inventoryService.When(x=>x.AcquireProduct(nokia, 2)).ThenReturn(false);

thrownException = ((MethodThatThrows)()=>
cart.Add(nokia, 2))
.GetException();
}

[Observation]
public void should_reject_the_request()
{
thrownException.ShouldNotBeNull();
}

[Observation]
public void should_not_acquire_product_from_inventory()
{
inventoryService.AssertWasNotCalled(x=>x.AcquireProduct(nokia, Arg.AnyInt));
}

[Observation]
public void should_not_add_any_product()
{
cart.Lines.ShouldBeEmpty();
}
}

public class when_a_first_time_customer_add_more_than_1_product_quantity:
Behaves_like_an_empty_shopping_cart
{
Exception exception;
public override void Because()
{
thrownException = ((MethodThatThrows)()=>
cart.Add(nokia, 2))
.GetException();
}

[Observation]
public void should_reject_the_request()
{
thrownException.ShouldNotBeNull();
}

[Observation]
public void should_not_acquire_product_from_inventory()
{
inventoryService.AssertWasNotCalled(x=>x.AcquireProduct(nokia, Arg.AnyInt));
}

[Observation]
public void should_not_add_any_product()
{
cart.Lines.ShouldBeEmpty();
}
}

public class when_a_first_time_customer_add_1_product_quantity:
Behaves_like_an_empty_shopping_cart
{
Exception exception;
public override void Because()
{
cart.Add(nokia, 1);
}

[Observation]
public void should_acquire_product_from_inventory()
{
inventoryService.AssertWasCalled(x=>x.AcquireProduct(nokia, 1));
}

[Observation]
public void cart_item_should_contain_the_product()
{
cart.GetLineFor(nokia).Quantity.ShouldBe(1);
}
}

[ConcernOf(“Shopping Cart”)]
public class Behaves_like_an_empty_shopping_cart: ContextSpecification
{
public override void Context()
{
cart = new ShoppingCart(
this.customer = new Customer(),
this.inventoryService = mocks.Mock<InventoryService>());
nokia = new MobileHandset(42342);

inventoryService.When(x=>x.AcquireProduct(Arg.Any<Product>(), Args.AnyInt)).ThenReturn(true);
}
}

public class Behaves_like_an_empty_shopping_cart_and_a_return_customer:
Behaves_like_an_empty_shopping_cart

{
public override void Context()
{
base.Context();
customer.OrderHistory.Add(createPreviousOrder());
}
}

Sekarang tiap scenario gak lagi ditulis dalam 1 (or several) methods. Tiap scenario dibikin jadi 1 class… dengan context yang jelas. Dan tiap method, isinya cuma 1 line assert doank. Rule-of-thumb BDD adalah 1 method tiap 1 assert… Jadi kita bisa lookup dengan cepat definisi dari tiap behavior yang diharapkan. Kita bisa lookup bahwa definisi dari cart_item_quantity_should_be_sum_of_added_quantities adalah cart.Lines.First().Quantity.ShouldBe(10 + 20);.

Begitu juga dengan tiap class yang represent context.. Didefine sebagai business vocabulary yang jelas.. Dan programmer bisa ngelookup definisi dari tiap business-context secara cepat. Misalnya when_inventory_runs_out_of_stock. Definisi dari “inventory runs out of stock” adalah inventoryService.When(x=>x.AcquireProduct(nokia, 2)).ThenReturn(false);
Jadi programmer bisa baca behavior of the system dengan gampang. Programmer bisa lookup definisi dari tiap business glossary cukup dengan ngebaca method Context ato Because. Jadi, definisi “inventory runs out of stock” dalam shopping cart adalah saat AcquireProduct returns false.

Biasanya semua classes ini gw tulis dalam 1 single file (e.g.. AddingProductToShoppingCartSpecification.cs). Dalam 1 file kita bisa ngebaca semua aspect dari sebuah sub-functionality dalam grammar Behaves_like, When, Should, Should, Should…
Setiap Behaves_like, setiap when, ato setiap should, langsung diikuti dengan definisinya.

Sekarang, tiap kali lo tulis scenario baru, lo gak perlu khawatir apakah ini bakal affect test-cases laen. Kalo scenario lo gak fit dengan context lo saat ini, lo tinggal create context baru. Tinggal bikin when_xxx baru… tanpa ngerefactor test-case lo yang laen. Ini juga ngebantu lo buat menghindari “workaround” yang males. Misalnya, di contoh first-time-customer di contoh TDD di atas, developer bisa ajah bikin method baru di customer buat clear all OrderHistory supaya return existing customer balik jadi first-time-customer buat saves him a lot of time buat refactor test-codenya. Ini bikin design yang smelly dan probably gak fit dengan domain yang mereka represent. (Di domain requirement, gak pernah ada customer nge-clear order history).

SpecUnit comes with a tool yang bakal compile code ini jadi HTML document… dengan grammar baku Specification for, behaves like, when, dan observations.
HTML ini bisa langsung dibaca oleh non-technical people. Saat business-people dah sering ngeliat HTML ini, dan mulai ngerti cara kerja lo, mereka ngeliat gimana requirement yang mereka jabarin kita convert jadi executable specifications dalam bentuk given-when-then. Soon enough mereka mulai excited dan mulai ngikut ngomong dalam gramamar ini.
Mereka tau tata-cara kita nulis code dalam bentuk specification, jadi mereka pun mulai ngomong requirement mereka dalam grammar: given-when-then… yang bisa immediately appear sebagai specification kita.. Nutup celah ambiguity. Bridge communication gap.

Iklan