Writing a testable story with Lettuce

Lettuce (http://lettuce.it) is a Cucumber-like BDD tool built for Python.

Cucumber (http://cukes.info) was developed by the Ruby community and provides a way to write scenarios in a textual style. By letting our stakeholders read the stories, they can easily discern what the software is expected to do.

This recipe shows how to install Lettuce, write a test story, and then wire it into our shopping cart application to exercise our code.

Getting ready

For this recipe, we will be using the shopping cart application shown at the beginning of this chapter. We also need to install Lettuce and its dependencies.

Install lettuce by typing pip install lettuce.

How to do it...

In the following steps, we will explore creating some testable stories with Lettuce, and wiring it to runnable Python code:

  1. Create a new folder called recipe32 to contain all the files in this recipe.
  2. Create a file named recipe32.feature to capture our story. Write the top-level description of our new feature, based on our shopping cart.
    Feature: Shopping cart
      As a shopper
      I want to load up items in my cart
      So that I can check out and pay for them
  3. Let's first create a scenario that captures the behavior of the cart when it's empty.
        Scenario: Empty cart
          Given an empty cart
          Then looking up the fifth item causes an error
          And looking up a negative price causes an error
          And the price with no taxes is $0.00
          And the price with taxes is $0.00
  4. Add another scenario that shows what happens when we add cartons of milk.
        Scenario: Cart getting loaded with multiple of the same
          Given an empty cart
          When I add a carton of milk for $2.50
          And I add another carton of milk for $2.50
          Then the first item is a carton of milk
          And the price is $5.00
          And the cart has 2 items
          And the total cost with 10% taxes is $5.50
  5. Add a third scenario that shows what happens when we combine a carton of milk and a frozen pizza.
        Scenario: Cart getting loaded with different items
          Given an empty cart
          When I add a carton of milk
          And I add a frozen pizza
          Then the first item is a carton of milk
          And the second item is a frozen pizza
          And the first price is $2.50
          And the second price is $3.00
          And the total cost with no taxes is $5.50
          And the total cost with 10% taes is $6.05
  6. Let's run the story through Lettuce to see what the outcome is, considering we haven't linked this story to any Python code. In the following screenshot, it's impossible to discern the color of the outputs. The feature and scenario declarations are white. Given, When, and Then are undefined and colored yellow. This shows that we haven't tied the steps to any code yet.
    How to do it...
  7. Create a new file in recipe32 called steps.py to implement the steps needed to support the Givens.
  8. Add some code to steps.py to implement the first Given.
    from lettuce import *
    from cart import *
    
    @step("an empty cart")
    def an_empty_cart(step):
        world.cart = ShoppingCart()
  9. To run the steps, we need to make sure the current path that contains the cart.py module is part of our PYTHONPATH.

    Tip

    For Linux and Mac OSX systems, type export PYTHONPATH=/path/to/cart.py.

    For Windows, go to Control Panel | System | Advanced, click Environment Variables, and either edit the existing PYTHONPATH variable or add a new one, pointing to the folder that contains cart.py.

  10. Run the stories again. It's hard to see in the following screenshot, but Given an empty cart is now green.
    How to do it...

    Note

    While this screenshot only focuses on the first scenario, all three scenarios have the same Givens. The code we wrote satisfied all three Givens.

  11. Add code to steps.py that implements support for the first scenario's Thens.
    @step("looking up the fifth item causes an error")
    def looking_up_fifth_item(step):
        try:
            world.cart.item(5)
            raise AssertionError("Expected IndexError")
        except IndexError, e:
            pass
    
    @step("looking up a negative price causes an error")
    def looking_up_negative_price(step):
        try:
            world.cart.price(-2)
            raise AssertionError("Expected IndexError")
        except IndexError, e:
            pass
    
    @step("the price with no taxes is (.*)")
    def price_with_no_taxes(step, total):
        assert world.cart.total(0.0) == float(total)
    
    @step("the price with taxes is (.*)")
    def price_with_taxes(step, total):
        assert world.cart.total(10.0) == float(total)
  12. Run the stories again and notice how the first scenario is completely passing.
    How to do it...
  13. Now add code to steps.py to implement the steps needed for the second scenario.
    @step("I add a carton of milk for (.*)")
    def add_a_carton_of_milk(step, price):
        world.cart.add("carton of milk", float(price))
    @step("I add another carton of milk for (.*)")
    def add_another_carton_of_milk(step, price):
        world.cart.add("carton of milk", float(price))
    
    @step("the first item is a carton of milk")
    def check_first_item(step):
        assert world.cart.item(1) == "carton of milk"
    
    @step("the price is (.*)")
    def check_first_price(step, price):
        assert world.cart.price(1) == float(price)
    
    @step("the cart has (.*) items")
    def check_size_of_cart(step, num_items):
        assert len(world.cart) == float(num_items)
    
    @step("the total cost with (.*)% taxes is (.*)")
    def check_total_cost(step, tax_rate, total):
        assert world.cart.total(float(tax_rate)) == float(total)
  14. Finally, add code to steps.py to implement the steps needed for the last scenario.
    @step("I add a carton of milk")
    def add_a_carton_of_milk(step):
        world.cart.add("carton of milk", 2.50)
    
    @step("I add a frozen pizza")
    def add_a_frozen_pizza(step):
        world.cart.add("frozen pizza", 3.00)
    
    @step("the second item is a frozen pizza")
    def check_the_second_item(step):
        assert world.cart.item(2) == "frozen pizza"
    
    @step("the first price is (.*)")
    def check_the_first_price(step, price):
        assert world.cart.price(1) == float(price)
    
    @step("the second price is (.*)")
    def check_the_second_price(step, price):
        assert world.cart.price(2) == float(price)
    
    @step("the total cost with no taxes is (.*)")
    def check_total_cost_with_no_taxes(step, total):
        assert world.cart.total(0.0) == float(total)
    
    @step("the total cost with (.*)% taxes is (.*)")
    def check_total_cost_with_taxes(step, tax_rate, total):
        assert round(world.cart.total(float(tax_rate)),2) == 
                                                  float(total)
  15. Run the story by typing lettuce recipe32 and see how they are all now passing. In the next screenshot, we have all the tests passing and everything is green.
    How to do it...

How it works...

Lettuce uses the popular Given/When/Then style of BDD story telling.

  • Givens: It involves setting up a scenario. This often includes creating objects. For each of our scenarios, we created an instance of the ShoppingCart. This is very similar to unittest's setup method.
  • Thens: It acts on the Givens. These are the operations we want to exercise in a scenario. We can exercise more than one Then.
  • Whens: It involves testing the final results of the Thens. In our code, we mostly used Python asserts. In a couple of cases, where we needed to detect an exception, we wrapped the call with a try-catch block with a throw if the expected exception didn't occur.

It doesn't matter in what order we put the Given/Then/When. Lettuce will record everything so that all the Givens are listed first, followed by all the Whens, and then all the Thens. Lettuce puts on the final polish by translating successive Given/When/Then into And for better readability.

There's more...

If you look closely at some of the steps, you will notice some wildcards.

@step("the total cost with (.*)% taxes is (.*)")
def check_total_cost(step, tax_rate, total):
    assert world.cart.total(float(tax_rate)) == float(total)

The @step string lets us dynamically grab parts of the string as variables by using pattern matchers.

  • The first (.*) is a pattern to capture the tax_rate
  • The second (.*) is a pattern to capture the total

The method definition shows these two extra variables added in. We can name them anything we want. This gives us the ability to actually drive the tests, data and all, from recipe32.feature and only use steps.py to link things together in a generalized way.

Tip

It's important to point out that actual values stored in tax_rate and total are Unicode strings. Because the test involves floating point numbers, we have to convert the variables or the assert fails.

How complex should a story be?

In this recipe, we fit everything into a single story. Our story involved all the various shopping cart operations. As we write more scenarios, we may expand this into multiple stories. This goes back to the concept discussed in the Breaking down obscure tests into simple ones section of Chapter 1. If we overload a single scenario with too many steps, it may get too complex. It is better if we can visualize a single thread of execution that is easy to verify at the end.

Don't mix wiring code with application code

The project's website shows a sample building a factorial function. It has both the factorial function as well as the wiring in a single file. For demo purposes this is alright. But for actual production use, it is best to decouple the application from the Lettuce wiring. This encourages a clean interface and demonstrates usability.

Lettuce works great using folders

Lettuce, by default, will look for a features folder wherever we run it, and discover any files ending in .feature. That way it can automatically find all of our stories and run them.

It is possible to override the features directory with -s or –-scenarios.

See also

Breaking down obscure tests into simple ones section from Chapter 1

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.144.97.187