3.2 Test Driven Development (TDD)#
Test-Driven Development (TDD) is a software development approach that prioritizes testing at the forefront of the development process. Central to TDD is the “Red-Green-Refactor” cycle, a repetitive pattern that guides developers through writing tests, implementing code, and refining the design.
In the “Red” phase, developers write failing tests that define the desired behavior of the system. These failing tests serve as a clear indicator of what needs to be implemented.
Next, in the “Green” phase, developers write the minimum amount of code necessary to make the failing tests pass. This phase focuses on fulfilling the requirements outlined by the tests, leading to the creation of functional code.
Finally, in the “Refactor” phase, developers improve the code’s structure, readability, and efficiency without changing its external behavior. This step ensures that the code remains maintainable and adheres to best practices.
By following the “Red-Green-Refactor” cycle iteratively, TDD encourages developers to write tests that drive the design and implementation of code, resulting in cleaner, more robust software that is thoroughly tested from the outset.
Applying TDD to build a Stock Portfolio solution#
To explore TDD in action, we will apply it to build a simple stock portfolio solution. The “Stock Portfolio” is a well-known example in the TDD community since it appears in Kent Beck’s book “Test-Driven Development: By Example.” Although in the original book, the solution is written in Java, we will implement it in Python.
We want to create a small report that keeps track of stocks and calculates their total value. The price of a stock is defined by the amount and the currency.
TODO (features to implement):
Multiplication. The value of each stock will be the number of shares times the price of the stock.
Sum. The total value of our portfolio will be the sum of the value of each stock.
Let’s implement the multiplication feature first. We will use “Red-Green-Refactor” cycle to do this. So we will start by writing a test that will fail. Then we will implement the feature and make the test pass. Finally, we will refactor the code.
Let’s tackle the first element of our TODO list, Multiplication.
Multiplication Test#
Red#
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Dollar(amount=5)
five.times(multiplier=2)
assert 10 == five.amount
Before we do anything else, let’s run pytest and see if we have any failing tests.
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 1 item
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication FAILED [100%]
============================================= FAILURES =============================================
_______________________________________ test_multiplication ________________________________________
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
> five = Dollar(amount=5)
E NameError: name 'Dollar' is not defined
/tmp/ipykernel_629125/2648424505.py:3: NameError
===================================== short test summary info ======================================
FAILED t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication - NameError: name 'Dollar' is not defined
======================================== 1 failed in 0.12s =========================================
<ExitCode.TESTS_FAILED: 1>
If we inspect our first test, we will notice that it is adding three new points to our TODO list. We won’t try to solve them now; instead, we will focus first on making our failing test pass.
TODO:
Multiplication
Sum
Make “amount” private
Dollar side-effects?
Money rounding?
Explanation:
Public Fields: The amount field of the Dollar class is public, which is generally considered bad practice in object-oriented design because it exposes the internal representation of the class. Good practice encourages keeping fields private and accessing them through methods to encapsulate the data.
Side-Effects: The times method appears to have a side effect, modifying the state of the Dollar object on which it is called. A more functional approach, or one that avoids side effects, would return a new Dollar object with the new amount, leaving the original object unchanged.
Integers for Monetary Amounts: Using integers to represent monetary amounts can be problematic due to rounding errors and lack of precision, especially when dealing with fractions of units. A more robust approach would be to use a decimal type or a class that represents monetary amounts.
Green#
Dollar is not defined. We need to define the Dollar class. Let’s do that now and run pytest again.
class Dollar:
pass
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 1 item
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication FAILED [100%]
============================================= FAILURES =============================================
_______________________________________ test_multiplication ________________________________________
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
> five = Dollar(amount=5)
E TypeError: Dollar() takes no arguments
/tmp/ipykernel_629125/2648424505.py:3: TypeError
===================================== short test summary info ======================================
FAILED t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication - TypeError: Dollar() takes no arguments
======================================== 1 failed in 0.01s =========================================
<ExitCode.TESTS_FAILED: 1>
We need to add a constructor to the Dollar class. The constructor needs to take the amount as an argument. Let’s do that now and rerun pytest.
class Dollar:
def __init__(self, amount: int):
pass
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 1 item
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication FAILED [100%]
============================================= FAILURES =============================================
_______________________________________ test_multiplication ________________________________________
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Dollar(amount=5)
> five.times(multiplier=2)
E AttributeError: 'Dollar' object has no attribute 'times'
/tmp/ipykernel_629125/2648424505.py:4: AttributeError
===================================== short test summary info ======================================
FAILED t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication - AttributeError: 'Dollar' object has no attribute 'times'
======================================== 1 failed in 0.01s =========================================
<ExitCode.TESTS_FAILED: 1>
Let’s define a times method.
class Dollar:
def __init__(self, amount):
pass
def times(self, multiplier):
pass
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 1 item
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication FAILED [100%]
============================================= FAILURES =============================================
_______________________________________ test_multiplication ________________________________________
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Dollar(amount=5)
five.times(multiplier=2)
> assert 10 == five.amount
E AttributeError: 'Dollar' object has no attribute 'amount'
/tmp/ipykernel_629125/2648424505.py:5: AttributeError
===================================== short test summary info ======================================
FAILED t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication - AttributeError: 'Dollar' object has no attribute 'amount'
======================================== 1 failed in 0.01s =========================================
<ExitCode.TESTS_FAILED: 1>
We need to add an amount atrribute to the Dollar class. Let’s do that now and rerun pytest.
class Dollar:
def __init__(self, amount):
pass
def times(self, multiplier):
pass
amount = None
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 1 item
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication FAILED [100%]
============================================= FAILURES =============================================
_______________________________________ test_multiplication ________________________________________
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Dollar(amount=5)
five.times(multiplier=2)
> assert 10 == five.amount
E assert 10 == None
E + where None = <__main__.Dollar object at 0x148a38ca4400>.amount
/tmp/ipykernel_629125/2648424505.py:5: AssertionError
===================================== short test summary info ======================================
FAILED t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication - assert 10 == None
======================================== 1 failed in 0.01s =========================================
<ExitCode.TESTS_FAILED: 1>
Let’s apply the easiest solution that could work. We will make amount equal to 10. Let’s rerun pytest.
class Dollar:
def __init__(self, amount):
pass
def times(self, multiplier):
pass
amount = 10
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 1 item
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [100%]
======================================== 1 passed in 0.01s =========================================
<ExitCode.OK: 0>
Refactor#
Now that we have a passing test we can move to refactoring our code. Let’s remove duplication in our code.
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Dollar(5)
five.times(2)
assert 10 == five.amount
# If we rewrite the 10 as 5 * 2, the duplication becomes more evident.
class Dollar:
def __init__(self, amount):
pass
def times(self, multiplier):
pass
amount = 5 * 2
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 1 item
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [100%]
======================================== 1 passed in 0.01s =========================================
<ExitCode.OK: 0>
There is duplication between the data in the test and the data in the code. Let’s remove that duplication by first moving amount to the times method.
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Dollar(5)
five.times(2)
assert 10 == five.amount
# If we rewrite the 10 as 5 * 2, the duplication becomes more evident.
class Dollar:
def __init__(self, amount):
pass
def times(self, multiplier):
self.amount = 5 * 2
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 1 item
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [100%]
======================================== 1 passed in 0.01s =========================================
<ExitCode.OK: 0>
Amount should be coming from the value passed to the constructor.
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Dollar(5)
five.times(2)
assert 10 == five.amount
# If we rewrite the 10 as 5 * 2, the duplication becomes more evident.
class Dollar:
def __init__(self, amount):
self.amount = amount
def times(self, multiplier):
self.amount = self.amount * 2
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 1 item
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [100%]
======================================== 1 passed in 0.01s =========================================
<ExitCode.OK: 0>
In the multiplication, 2 is the value of the multiplier.
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Dollar(5)
five.times(2)
assert 10 == five.amount
# If we rewrite the 10 as 5 * 2, the duplication becomes more evident.
class Dollar:
def __init__(self, amount):
self.amount = amount
def times(self, multiplier):
self.amount = self.amount * multiplier
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 1 item
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [100%]
======================================== 1 passed in 0.01s =========================================
<ExitCode.OK: 0>
We can make it more pythonic using *= in times.
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Dollar(5)
five.times(2)
assert 10 == five.amount
# If we rewrite the 10 as 5 * 2, the duplication becomes more evident.
class Dollar:
def __init__(self, amount):
self.amount = amount
def times(self, multiplier):
self.amount *= multiplier
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 1 item
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [100%]
======================================== 1 passed in 0.01s =========================================
<ExitCode.OK: 0>
Side-effects#
Red#
Create a test to see the side effects of multiplication. We will create a test to see if the original object is modified after multiplication.
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Dollar(5)
five.times(2)
assert 10 == five.amount
five.times(3)
assert 15 == five.amount
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 1 item
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication FAILED [100%]
============================================= FAILURES =============================================
_______________________________________ test_multiplication ________________________________________
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Dollar(5)
five.times(2)
assert 10 == five.amount
five.times(3)
> assert 15 == five.amount
E assert 15 == 30
E + where 30 = <__main__.Dollar object at 0x148a38cd94b0>.amount
/tmp/ipykernel_629125/2074498160.py:7: AssertionError
===================================== short test summary info ======================================
FAILED t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication - assert 15 == 30
======================================== 1 failed in 0.01s =========================================
<ExitCode.TESTS_FAILED: 1>
At the moment, when we multiply a Dollar, the amount changes. This creates what we call a side-effect - the original object is modified during the operation.
Let’s examine this behavior more closely. When we call five.times(2)
followed by five.times(3)
, we’re expecting the second operation to multiply the original value (5) by 3, not the already-modified value (10) by 3. Side effects like this can make code unpredictable and harder to reason about.
To solve this problem, we’ll modify our times
method to return a new Dollar object instead of modifying the existing one. This approach transforms our Dollar class into what’s known as a value object.
Value Objects and Immutability
A value object is an object that represents a simple entity whose equality is based on its value rather than its identity. Two Dollar objects with the same amount should be considered equal, regardless of whether they’re the same object in memory.
An immutable object is one whose state cannot be changed after creation. By making our Dollar class immutable, we eliminate side effects entirely - every operation returns a new object, leaving the original unchanged.
These concepts work together beautifully: value objects are typically implemented as immutable objects because their identity is based on their values, not their memory location. This design pattern is widely recognized in Domain-Driven Design and helps create more predictable, maintainable code.
Making Dollar an immutable value object will solve our side-effect problem and make our code more robust.
We want the times method to return a new Dollar object with the new amount instead of modifying the current object. There is no easy way to fix the test. We need to modify the test to reflect the new behaviour.
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Dollar(5)
ten = five.times(2)
assert 10 == ten.amount
fifteen = five.times(3)
assert 15 == fifteen.amount
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 1 item
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication FAILED [100%]
============================================= FAILURES =============================================
_______________________________________ test_multiplication ________________________________________
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Dollar(5)
ten = five.times(2)
> assert 10 == ten.amount
E AttributeError: 'NoneType' object has no attribute 'amount'
/tmp/ipykernel_629125/1909386861.py:5: AttributeError
===================================== short test summary info ======================================
FAILED t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication - AttributeError: 'NoneType' object has no attribute 'amount'
======================================== 1 failed in 0.01s =========================================
<ExitCode.TESTS_FAILED: 1>
Green#
With the right test, now let’s fix it by creating a new Dollar object with the new amount.
class Dollar:
def __init__(self, amount):
self.amount = amount
def times(self, multiplier):
return Dollar(self.amount * multiplier)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 1 item
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [100%]
======================================== 1 passed in 0.01s =========================================
<ExitCode.OK: 0>
TODO:
Multiplication DONE
Make “amount” private
Dollar side-effects? DONE
Money rounding?
Sum
Implications of Value Objects
since they are inmutable objects, we know that no operation will change the state of the object.
they should implement equals(): We need to specify when two value objects are equal.
they should implement hash(): The hash code of objects that are consider equal needs to be the same.
TODO:
Multiplication DONE
Make “amount” private
Dollar side-effects? DONE
Money rounding?
Sum
equals()
hash()
Equality#
Red#
def test_equality():
assert Dollar(5) == Dollar(5)
assert Dollar(5) != Dollar(6)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 2 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 50%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality FAILED [100%]
============================================= FAILURES =============================================
__________________________________________ test_equality ___________________________________________
def test_equality():
> assert Dollar(5) == Dollar(5)
E assert <__main__.Dollar object at 0x148a38f28220> == <__main__.Dollar object at 0x148a38f2b940>
E + where <__main__.Dollar object at 0x148a38f28220> = Dollar(5)
E + and <__main__.Dollar object at 0x148a38f2b940> = Dollar(5)
/tmp/ipykernel_629125/3170841363.py:2: AssertionError
===================================== short test summary info ======================================
FAILED t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality - assert <__main__.Dollar object at 0x148a38f28220> == <__main__.Dollar object at 0x148a38f2b940>
=================================== 1 failed, 1 passed in 0.01s ====================================
<ExitCode.TESTS_FAILED: 1>
Generalization (or Triangulation when learning from 2 examples). We are trying to find the general solution, by making it fit more than one example. Does it remind you of something? Yes, it’s the same principle we used when we were triangulating the implementation of the multiplication. We started with a specific example (5 * 2 = 10) and then we generalized it to a more general case (5 * 2 = 10 and 5 * 3 = 15). First, we overfited to 10, then we generalized to amount * multiplier.
Green#
Let’s make the test pass implementing the __eq__ method.
class Dollar:
def __init__(self, amount):
self.amount = amount
def times(self, multiplier):
return Dollar(self.amount * multiplier)
def __eq__(self, other):
return self.amount == other.amount
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 2 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 50%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [100%]
======================================== 2 passed in 0.01s =========================================
<ExitCode.OK: 0>
TODO list:
Multiplication DONE
Make “amount” private
Dollar side-effects? DONE
Money rounding?
Sum
equals() DONE
hash()
Make “amount” private#
Refactor#
We want times to return a new Dollar object. We want to make the amount field private. We will need to change the tests to reflect this change.
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Dollar(5)
ten = five.times(2)
assert 10 == ten.amount
fifteen = five.times(3)
assert 15 == fifteen.amount
becomes
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Dollar(5)
ten = five.times(2)
assert Dollar(10) == ten
fifteen = five.times(3)
assert Dollar(15) == fifteen
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 2 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 50%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [100%]
======================================== 2 passed in 0.01s =========================================
<ExitCode.OK: 0>
We can simplify the tests, removing the intermediate variables.
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Dollar(5)
assert Dollar(10) == five.times(2)
assert Dollar(15) == five.times(3)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 2 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 50%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [100%]
======================================== 2 passed in 0.01s =========================================
<ExitCode.OK: 0>
Now we can make amount private.
class Dollar:
def __init__(self, amount):
self._amount = amount
def times(self, multiplier):
return Dollar(self._amount * multiplier)
def __eq__(self, other):
return self._amount == other._amount
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 2 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 50%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [100%]
======================================== 2 passed in 0.01s =========================================
<ExitCode.OK: 0>
TODO:
Multiplication DONE
Make “amount” private DONE
Dollar side-effects? DONE
Money rounding?
Sum
equals() DONE
hash()
A Hidden Regression in Our Equality Implementation
When we implemented the __eq__
method for our Dollar class, we inadvertently introduced a regression. Our current implementation:
def __eq__(self, other):
return self._amount == other._amount
This code assumes that other
always has an _amount
attribute. However, this breaks Python’s standard behavior when comparing objects with None
or other types.
What Python objects normally do:
mydict = {'a': 1, 'b': 2}
mylist = ['a', 1, 'b', 2]
print(mydict == mylist) # False (works fine)
print(mydict == None) # False (works fine)
What our Dollar class does:
dollar = Dollar(5)
print(dollar == None) # AttributeError: 'NoneType' has no attribute '_amount'
print(dollar == "hello") # AttributeError: 'str' has no attribute '_amount'
Why this matters:
This regression is particularly problematic because:
Breaks Python conventions - Python objects are expected to handle comparison with any type gracefully
Real-world usage patterns - Code often checks
if my_object == None:
or compares different object typesDefensive programming - Robust code should handle unexpected inputs without crashing
The lesson:
This demonstrates how implementing one feature (equality) can inadvertently break existing capabilities. In TDD, this reminds us to:
Consider edge cases when implementing new functionality
Add comprehensive tests for boundary conditions
Be aware that even simple changes can have unintended consequences
We should add this regression fix to our TODO list to ensure our Dollar objects behave like proper Python citizens.
Let’s add those features to the list.
TODO:
Multiplication DONE
Make “amount” private DONE
Dollar side-effects? DONE
Money rounding?
Sum
equals() DONE
hash()
__eq__(None)
__eq__(other_object)
Multiplication for Francs#
Red#
Now let’s expand our multiplication feature to support Swiss Francs. Following our TDD approach, we’ll start by writing a test for Franc multiplication, similar to what we did for Dollars.
def test_franc_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Franc(5)
assert Franc(10) == five.times(2)
assert Franc(15) == five.times(3)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 3 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 33%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 66%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication FAILED [100%]
============================================= FAILURES =============================================
____________________________________ test_franc_multiplication _____________________________________
def test_franc_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
> five = Franc(5)
E NameError: name 'Franc' is not defined
/tmp/ipykernel_629125/755619237.py:3: NameError
===================================== short test summary info ======================================
FAILED t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication - NameError: name 'Franc' is not defined
=================================== 1 failed, 2 passed in 0.02s ====================================
<ExitCode.TESTS_FAILED: 1>
With this new test, we now have 3 new issues.
TODO:
Multiplication DONE
Make “amount” private DONE
Dollar side-effects? DONE
Money rounding?
Sum
equals() DONE
hash()
__eq__(None)
__eq__(other_object)
Dollar/Franc duplication
Common equals
Common times
Green#
Let’s just copy/past the Dollar class and adapt it to Franc.
class Franc:
def __init__(self, amount):
self._amount = amount
def times(self, multiplier):
return Franc(self._amount * multiplier)
def __eq__(self, other):
return self._amount == other._amount
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 3 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 33%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 66%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [100%]
======================================== 3 passed in 0.01s =========================================
<ExitCode.OK: 0>
Refactor#
To better support multicurrency, we need to remove the duplication between Dollar and Franc. We can do this by creating a common parent class for Dollar and Franc called Money.
class Money:
pass
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 3 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 33%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 66%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [100%]
======================================== 3 passed in 0.01s =========================================
<ExitCode.OK: 0>
Dollar should inherit from Money.
class Dollar(Money):
def __init__(self, amount):
self._amount = amount
def times(self, multiplier):
return Dollar(self._amount * multiplier)
def __eq__(self, other):
return self._amount == other._amount
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 3 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 33%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 66%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [100%]
======================================== 3 passed in 0.01s =========================================
<ExitCode.OK: 0>
Let’s move amount to Money
class Money:
def __init__(self, amount):
self._amount = amount
class Dollar(Money):
def times(self, multiplier):
return Dollar(self._amount * multiplier)
def __eq__(self, other):
return self._amount == other._amount
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 3 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 33%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 66%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [100%]
======================================== 3 passed in 0.01s =========================================
<ExitCode.OK: 0>
Now move __eq__ to Money.
class Money:
def __init__(self, amount):
self._amount = amount
def __eq__(self, other):
return self._amount == other._amount
class Dollar(Money):
def times(self, multiplier):
return Dollar(self._amount * multiplier)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 3 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 33%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 66%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [100%]
======================================== 3 passed in 0.01s =========================================
<ExitCode.OK: 0>
Add tests for francs equality.
def test_equality():
assert Dollar(5) == Dollar(5)
assert Dollar(5) != Dollar(6)
assert Franc(5) == Franc(5)
assert Franc(5) != Franc(6)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 3 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 33%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 66%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [100%]
======================================== 3 passed in 0.01s =========================================
<ExitCode.OK: 0>
Make Franc inherite from Money.
class Money:
def __init__(self, amount):
self._amount = amount
def __eq__(self, other):
return self._amount == other._amount
class Dollar(Money):
def times(self, multiplier):
return Dollar(self._amount * multiplier)
class Franc(Money):
def __init__(self, amount):
self._amount = amount
def times(self, multiplier):
return Franc(self._amount * multiplier)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 3 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 33%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 66%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [100%]
======================================== 3 passed in 0.01s =========================================
<ExitCode.OK: 0>
Remove the constructor from Franc.
class Money:
def __init__(self, amount):
self._amount = amount
def __eq__(self, other):
return self._amount == other._amount
class Dollar(Money):
def times(self, multiplier):
return Dollar(self._amount * multiplier)
class Franc(Money):
def times(self, multiplier):
return Franc(self._amount * multiplier)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 3 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 33%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 66%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [100%]
======================================== 3 passed in 0.01s =========================================
<ExitCode.OK: 0>
Common equals#
Red#
What if we want to compare a Dollar with a Franc? We need to add a test for that.
TODO:
Multiplication DONE
Make “amount” private DONE
Dollar side-effects? DONE
Money rounding?
Sum
equals() DONE
hash()
__eq__(None)
__eq__(other_object)
Dollar/Franc duplication
Common equals
Common times
def test_equality():
assert Dollar(5) == Dollar(5)
assert Dollar(5) != Dollar(6)
assert Franc(5) == Franc(5)
assert Franc(5) != Franc(6)
assert Dollar(5) != Franc(5)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 3 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 33%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality FAILED [ 66%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [100%]
============================================= FAILURES =============================================
__________________________________________ test_equality ___________________________________________
def test_equality():
assert Dollar(5) == Dollar(5)
assert Dollar(5) != Dollar(6)
assert Franc(5) == Franc(5)
assert Franc(5) != Franc(6)
> assert Dollar(5) != Franc(5)
E assert <__main__.Dollar object at 0x148a38b4ca30> != <__main__.Franc object at 0x148a38b4c4c0>
E + where <__main__.Dollar object at 0x148a38b4ca30> = Dollar(5)
E + and <__main__.Franc object at 0x148a38b4c4c0> = Franc(5)
/tmp/ipykernel_629125/4073365173.py:6: AssertionError
===================================== short test summary info ======================================
FAILED t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality - assert <__main__.Dollar object at 0x148a38b4ca30> != <__main__.Franc object at 0x148a38b4c4c0>
=================================== 1 failed, 2 passed in 0.01s ====================================
<ExitCode.TESTS_FAILED: 1>
Green#
Fix it by adding type() comparisons to the Money equality method.
class Money:
def __init__(self, amount):
self._amount = amount
def __eq__(self, other):
return self._amount == other._amount and type(self) == type(other)
class Dollar(Money):
def times(self, multiplier):
return Dollar(self._amount * multiplier)
class Franc(Money):
def times(self, multiplier):
return Franc(self._amount * multiplier)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 3 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 33%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 66%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [100%]
======================================== 3 passed in 0.01s =========================================
<ExitCode.OK: 0>
Refactor#
If we want to eliminate the two classes Dollar and Franc, we first need to remove the direct references in the test. We can do this by creating factory methods in Money that creates Dollars and Francs as needed.
Create factory method dollar.
class Money:
def __init__(self, amount):
self._amount = amount
def __eq__(self, other):
return self._amount == other._amount and type(self) == type(other)
def dollar(amount):
return Dollar(amount)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 3 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 33%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 66%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [100%]
======================================== 3 passed in 0.01s =========================================
<ExitCode.OK: 0>
Use the new dollar factory method in the tests.
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Money.dollar(amount=5)
assert Money.dollar(amount=10) == five.times(multiplier=2)
assert Money.dollar(amount=15) == five.times(multiplier=3)
def test_franc_multiplication():
# test that you can multiply a Franc by a number and get the right amount.
five = Franc(amount=5)
assert Franc(amount=10) == five.times(multiplier=2)
assert Franc(amount=15) == five.times(multiplier=3)
def test_equality():
assert Money.dollar(3) == Money.dollar(3)
assert Money.dollar(3) != Money.dollar(4)
assert Franc(3) == Franc(3)
assert Franc(3) != Franc(4)
assert Money.dollar(5) != Franc(5)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 3 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 33%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 66%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [100%]
======================================== 3 passed in 0.01s =========================================
<ExitCode.OK: 0>
Create factory method franc.
class Money:
def __init__(self, amount):
self._amount = amount
def __eq__(self, other):
return self._amount == other._amount and type(self) == type(other)
def dollar(amount):
return Dollar(amount)
def franc(amount):
return Franc(amount)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 3 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 33%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 66%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [100%]
======================================== 3 passed in 0.01s =========================================
<ExitCode.OK: 0>
The dollar()
and franc()
factory methods we just implemented follow the Factory Method pattern from the Gang of Four design patterns book (“Design Patterns: Elements of Reusable Object-Oriented Software” by Gamma, Helm, Johnson, and Vlissides).
The Factory Method pattern provides an interface for creating objects without specifying their exact class. In our case, Money.dollar()
and Money.franc()
hide the complexity of creating Dollar
and Franc
objects, allowing us to potentially change the implementation later without affecting client code.
This pattern becomes particularly useful as we refactor toward a single Money
class, demonstrating how design patterns can facilitate clean code evolution during TDD.
Replace in the tests the direct references to Franc.
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Money.dollar(amount=5)
assert Money.dollar(amount=10) == five.times(multiplier=2)
assert Money.dollar(amount=15) == five.times(multiplier=3)
def test_franc_multiplication():
# test that you can multiply a Franc by a number and get the right amount.
five = Money.franc(amount=5)
assert Money.franc(amount=10) == five.times(multiplier=2)
assert Money.franc(amount=15) == five.times(multiplier=3)
def test_equality():
assert Money.dollar(3) == Money.dollar(3)
assert Money.dollar(3) != Money.dollar(4)
assert Money.franc(3) == Money.franc(3)
assert Money.franc(3) != Money.franc(4)
assert Money.dollar(5) != Money.franc(5)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 3 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 33%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 66%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [100%]
======================================== 3 passed in 0.01s =========================================
<ExitCode.OK: 0>
Multicurrency#
Red#
Add a test for currency.
def test_currency():
assert "USD" == Money.dollar(1)._currency
assert "CHF" == Money.franc(1)._currency
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 4 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 25%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 50%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [ 75%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_currency FAILED [100%]
============================================= FAILURES =============================================
__________________________________________ test_currency ___________________________________________
def test_currency():
> assert "USD" == Money.dollar(1)._currency
E AttributeError: 'Dollar' object has no attribute '_currency'
/tmp/ipykernel_629125/913078098.py:2: AttributeError
===================================== short test summary info ======================================
FAILED t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_currency - AttributeError: 'Dollar' object has no attribute '_currency'
=================================== 1 failed, 3 passed in 0.01s ====================================
<ExitCode.TESTS_FAILED: 1>
Green#
Fix the test adding currency to the classes.
class Dollar(Money):
def times(self, multiplier):
return Dollar(self._amount * multiplier)
_currency = "USD"
class Franc(Money):
def times(self, multiplier):
return Franc(self._amount * multiplier)
_currency = "CHF"
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 4 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 25%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 50%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [ 75%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_currency PASSED [100%]
======================================== 4 passed in 0.01s =========================================
<ExitCode.OK: 0>
Refactor#
Refactor by moving currency to the constructor of Money.
class Money:
def __init__(self, amount, currency):
self._amount = amount
self._currency = currency
def __eq__(self, other):
return self._amount == other._amount and type(self) == type(other)
def dollar(amount):
return Dollar(amount,"USD")
def franc(amount):
return Franc(amount,"CHF")
class Dollar(Money):
def times(self, multiplier):
return Money.dollar(self._amount * multiplier)
class Franc(Money):
def times(self, multiplier):
return Money.franc(self._amount * multiplier)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 4 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 25%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 50%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [ 75%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_currency PASSED [100%]
======================================== 4 passed in 0.01s =========================================
<ExitCode.OK: 0>
Add times method to Money.
class Money:
def __init__(self, amount, currency):
self._amount = amount
self._currency = currency
def __eq__(self, other):
return self._amount == other._amount and type(self) == type(other)
def dollar(amount):
return Dollar(amount,"USD")
def franc(amount):
return Franc(amount,"CHF")
def times(self, multiplier):
return Money(self._amount * multiplier, self._currency)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 4 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 25%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 50%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [ 75%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_currency PASSED [100%]
======================================== 4 passed in 0.01s =========================================
<ExitCode.OK: 0>
Move the factory methods of dollar and franc to Money.
class Money:
def __init__(self, amount, currency):
self._amount = amount
self._currency = currency
def __eq__(self, other):
return self._amount == other._amount and type(self) == type(other)
def dollar(amount):
return Money(amount,"USD")
def franc(amount):
return Money(amount,"CHF")
def times(self, multiplier):
return Money(self._amount * multiplier, self._currency)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 4 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 25%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality FAILED [ 50%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [ 75%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_currency PASSED [100%]
============================================= FAILURES =============================================
__________________________________________ test_equality ___________________________________________
def test_equality():
assert Money.dollar(3) == Money.dollar(3)
assert Money.dollar(3) != Money.dollar(4)
assert Money.franc(3) == Money.franc(3)
assert Money.franc(3) != Money.franc(4)
> assert Money.dollar(5) != Money.franc(5)
E assert <__main__.Money object at 0x148a38f2b850> != <__main__.Money object at 0x148a38f2bc40>
E + where <__main__.Money object at 0x148a38f2b850> = <function Money.dollar at 0x148a38d3e680>(5)
E + where <function Money.dollar at 0x148a38d3e680> = Money.dollar
E + and <__main__.Money object at 0x148a38f2bc40> = <function Money.franc at 0x148a38d3f7f0>(5)
E + where <function Money.franc at 0x148a38d3f7f0> = Money.franc
/tmp/ipykernel_629125/1919423556.py:18: AssertionError
===================================== short test summary info ======================================
FAILED t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality - assert <__main__.Money object at 0x148a38f2b850> != <__main__.Money object at 0x148a38f2bc40>
=================================== 1 failed, 3 passed in 0.01s ====================================
<ExitCode.TESTS_FAILED: 1>
Fix the test again! Change type comparison for currency comparison in equality
class Money:
def __init__(self, amount, currency):
self._amount = amount
self._currency = currency
def __eq__(self, other):
return self._amount == other._amount and self._currency == other._currency
def dollar(amount):
return Money(amount,"USD")
def franc(amount):
return Money(amount,"CHF")
def times(self, multiplier):
return Money(self._amount * multiplier, self._currency)
ipytest.run('-vv') # '-vv' for increased verbosity
======================================= test session starts ========================================
platform linux -- Python 3.10.4, pytest-8.0.0, pluggy-1.4.0 -- /home/callaram/.conda/envs/jupyterbook/bin/python
cachedir: .pytest_cache
rootdir: /home/callaram/tds/msdp-book/ch3
collecting ... collected 4 items
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_multiplication PASSED [ 25%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_equality PASSED [ 50%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_franc_multiplication PASSED [ 75%]
t_fb3f9ce7a64348adbc88e6f0309c9d3b.py::test_currency PASSED [100%]
======================================== 4 passed in 0.01s =========================================
<ExitCode.OK: 0>
Remove Dollar and Franc classes.
class Money:
def __init__(self, amount, currency):
self._amount = amount
self._currency = currency
def __eq__(self, other):
return self._amount == other._amount and self._currency == other._currency
def dollar(amount):
return Money(amount,"USD")
def franc(amount):
return Money(amount,"CHF")
def times(self, multiplier):
return Money(self._amount * multiplier, self._currency)
Congrats!#
You have used TDD to implement the first features of the Stock Portfolio solution. Let’s take a look into the final code.
def test_multiplication():
# test that you can multiply a Dollar by a number and get the right amount.
five = Money.dollar(amount=5)
assert Money.dollar(amount=10) == five.times(multiplier=2)
assert Money.dollar(amount=15) == five.times(multiplier=3)
def test_franc_multiplication():
# test that you can multiply a Franc by a number and get the right amount.
five = Money.franc(amount=5)
assert Money.franc(amount=10) == five.times(multiplier=2)
assert Money.franc(amount=15) == five.times(multiplier=3)
def test_equality():
assert Money.dollar(3) == Money.dollar(3)
assert Money.dollar(3) != Money.dollar(4)
assert Money.franc(3) == Money.franc(3)
assert Money.franc(3) != Money.franc(4)
assert Money.dollar(5) != Money.franc(5)
def test_currency():
assert "USD" == Money.dollar(1)._currency
assert "CHF" == Money.franc(1)._currency
class Money:
def __init__(self, amount, currency):
self._amount = amount
self._currency = currency
def __eq__(self, other):
return self._amount == other._amount and self._currency == other._currency
def dollar(amount):
return Money(amount,"USD")
def franc(amount):
return Money(amount,"CHF")
def times(self, multiplier):
return Money(self._amount * multiplier, self._currency)