DEV Community

Arpple
Arpple

Posted on

writing test and BDD

this post will focus on no side effect environment module testing, and the code is not in any programming language :P

intro

let's first take a look at simple how-to write a test that I sees people usually do now
suppose we have module (or class) Cart
we will follow the OO concept of encapsulating state as private and have a public method

module Cart
  private items

  public fn isEmpty()
  public fn addItem(item)
  public fn checkOut()
Enter fullscreen mode Exit fullscreen mode

just to be simple we will use item as string "A", "B" and the checkout will just print format list of purchased items

> cart.addItem("A")
> cart.checkout()
Order: A
> cart.addItem("B")
> cart.checkout()
Order: A, B
Enter fullscreen mode Exit fullscreen mode

now let's start writing a test on each function, start with the very easy first test on isEmpty()

module CartTest
  describe "isEmpty()"
    test "it return true when no item"
      cart = new Cart()
      assert cart.isEmpty() == true
Enter fullscreen mode Exit fullscreen mode

this is so simple. let's see the next method addItem()

describe "addItem()"
  test "it return ..."
Enter fullscreen mode Exit fullscreen mode

more complicate

we already stuck at the name of test. this method just add the item to cart without returning any result
you can make a method return boolean to represent the success/fail result but it still not what we expect a method to do, to actually put item in the cart
instead of checking the result, we want to check the state of the cart for it's items but the items property is private
the quick easy way would be

module Cart
-  private items
+  public items
Enter fullscreen mode Exit fullscreen mode

or

module Cart
+  public fn getItems()
Enter fullscreen mode Exit fullscreen mode

we change the items to be exposed to public scope. now we can test

describe "addItem()"
  test "it add item to cart's items list"
    cart = new Cart()
    assert cart.items == []   
    cart.addItem("A")
    assert cart.items == ["A"]
Enter fullscreen mode Exit fullscreen mode

but this break a concept of encapsulation and make a test become a white box testing. we would prefer black box approach because we don't want the detail and logic of module to grow outside of itself and become hard to understand

!

making this an ORM model and asserting the database row doesn't mean it solve this problem

let's just keep it like this and see the next test
the isEmpty() test we wrote is not covered all the cases it can be. also we want to test addItem() with multiple item.
we need to setup test with already added item. someone may come up with this solution

describe "isEmpty()"
  test "it return true when no item"
    ...

  test "it return false when cart have items"
    cart = new Cart()
    cart.items = ["A"]
    assert cart.isEmpty() == false

describe "addItem()"
  test "it add item to cart"
    ...

  test "it can add more item to cart"
    cart = new Cart()
    cart.items = ["A"]
    cart.addItem("B")
    assert cart.items == ["A", "B"]
Enter fullscreen mode Exit fullscreen mode

by setting items directly (or create public fn setItems to set it). you can isolate a test to just it's testing method.
also if some method require more pre-setup state, setting up state directly often be the quick way to do.

the problem is, this is not the actual usage of your module. by directly setting state, it can happen to be a "shouldn't exists state" which mean you are testing the thing that not actually exists in real code

stupid example
  module Cart
    public isActive
    public isDelete

    public fn activate()
      this.isActive = true
      this.isDelete = false

  public fn delete()
      this.isActive = false
      this.isDelete = true
Enter fullscreen mode Exit fullscreen mode

by using public method, both flags won't result in same true/false state. but with manual setup, you may create

cart = new Cart()
cart.isActive = true
cart.isDelete = true
Enter fullscreen mode Exit fullscreen mode

also as mentioned before, the state shouldn't be exposed out of module. so this kind of setup is not a way we want to do.

!
making this an ORM model and pre insert into database row doesn't mean it solve this problem

taking a step back

if you just want code and howto, skip this part :P

for now we have 2 problems

  • we want to test the fn that modify state but don't want to expose the state
  • we want to setup pre-state but also don't want to expose the state

this may feel like a paradox that cannot be solved. But let's take a step back and look at the bigger picture of module with methods and state.

imagine if we build a boiler machine.
we may not want to wire the power into the heater part to test that this machine can boil the water without testing the turn on button.
for this context, we want to test the complete machine not a component

the same with module, many times that it is designed that each method cannot live by itself, but work together as a state diagram
img
look at this example state machine diagram
not all transition is drawn out from the initial state so it shouldn't work without previous transition and you shouldn't jump to any state without transition

apply this with code module, not all method will work without setup by some other method and you can't set the state directly.

for this I want you to forget about testing individual method and instead focus on testing the Behavior of entire module or BDD

yes BDD is not just forcing test name with awkward Given When Then and don't need to be on the top level end-to-end or feature test

Let's do BDD

from the example above we may see that testing single method is not work well with encapsulated black box logic because the whole module isn't mean to be used by just that single method alone.
let's look at the problem we found again

describe "addItem()"
  test "it add item to cart's items list"
    cart = new Cart()
    assert cart.items == []   
    cart.addItem("A")
    assert cart.items == ["A"]
Enter fullscreen mode Exit fullscreen mode

because we can't verify the result of addItem() alone but we can look in another way, the addItem() is actually the passage to the checkout(). So we will test both of them together

describe "checkout()"
  test "it print order with single item"
    cart = new Cart()
    cart.addItem("A")
    assert cart.checkout == "Order: A"
Enter fullscreen mode Exit fullscreen mode

we don't care to describe what addItem() do behind the scene. we only know that we need it before checkout(). so there is nothing like list or something internal in the test at all

the next problem was about setup pre-state, again we can just use the addItem together

describe "isEmpty()"
  test "it return false when cart have items"
    cart = new Cart()
    cart.addItem("A")
    assert cart.isEmpty() == false

describe "checkout()"
  test "it print order with single item"
    ...

  test "it print order with multiple item"
    cart = new Cart()
    cart.addItem("A")
    cart.addItem("B")
    assert cart.checkout == "Order: A, B"
Enter fullscreen mode Exit fullscreen mode

and because we don't focus a test to just single method, let's remove a describe with the method name from tests and rename some tests

module CartTest
- describe "isEmpty()"
    test "new cart is empty"
    test "cart with single item is not empty"
    test "cart with multiple items is not empty"

- describe "checkout()"
    test "new cart cannot checkout"
    test "print order from cart with single item"
    test "print order from cart with multiple items"
Enter fullscreen mode Exit fullscreen mode

we can see some pattern here. there are 3 tests per (removed) describe scope with the new cart, single item and multiple items in both of them. let's try group them together

describe "new cart"
  cart = new Cart()
  test "should be empty"
  test "cannot checkout"

describe "cart with single item"
  cart = new Cart()
  cart.addItem("A")
  test "should not be empty"
  test "should print order with single item"

describe "cart with multiple items"
  cart = new Cart()
  cart.addItem("A")
  cart.addItem("B")
  test "should not be empty"
  test "should print order with multiple items"
Enter fullscreen mode Exit fullscreen mode

by grouping these together, not only we get the shorter name. we can also share the setup code in each describe block because it is tested on the same state

and if you try hard enough, you can phrase it to the given-when-then pattern.

given new cart
  when check empty
    then it return true 
  when checkout
    then it return error
Enter fullscreen mode Exit fullscreen mode

given map directly to the setup state
when is the method or action we execute
then map expected result

I think this test pattern is a lot more easier to map to these GWT format than the previous per-method pattern because we test the module by actual Behavior thus BDD

Summary

  • testing per method is hard because some method is not self-completed
  • instead we want to test behavior of entire module using only public method (Black Box)
  • we group test by pre setup state so we can share setup code
  • the test is more reflect to real world use-case

!
this example may not be ideal. because in real case, we may not want items to be private because UI will need to show list of it
and checkout should actually be outside of cart (another service/module) because the dependency should be Order -> Cart to avoid cart being God Object

Extra

I recommend to have multiple it or then per test if need. In this way it is easier to describe the behavior of your module
for example

when cancel an order, mark status as 'canceled', update timestamp and fire event order canceled

even grouping by state and describe logic in the test name, it can make test name and body too long

describe "created order"
  it "cancel order mark status as 'canceled', update timestamp and fire event"

  // and because its very long, we most likely do
  it "can cancel successfully"
Enter fullscreen mode Exit fullscreen mode

better example: test by behavior, descriptive name, separate each assertion

describe "created order"
  describe "when cancel"
    test "it mark status as 'canceled"
    test "it update timestamp"
    test "it fire event"
Enter fullscreen mode Exit fullscreen mode

separate each topic in it's own test/it/then is easier to add new requirement or adjust existing one and test report will looks very clean and can better describe behavior of the code

Discussion (3)

Collapse
arpple profile image
Arpple Author

can I know exactly what you need it for?