In this lesson, we'll deepen our understanding of the Test-Driven Development (TDD) mindset using Scala 3 and ScalaTest by focusing on the Red-Green-Refactor cycle. We'll work through a practical example centered on a calculateTotal
function, guiding you through thinking with tests, prioritizing test writing, and leveraging TDD to enhance code clarity, reliability, and maintainability.
Using Scala and ScalaTest, we'll follow these steps:
- Begin with the Red phase by identifying and writing failing tests for the
calculateTotal
function, which will compute the total price of items in a shopping cart. - Move to the Green phase to implement the minimal code required to pass each test, ensuring that the
calculateTotal
function behaves as expected. - Enter the Refactor phase to improve the code structure and readability of
calculateTotal
, employing Scala’s functional programming features for aggregation while keeping tests green. - Utilize
ScalaTest
as the testing framework to efficiently integrate the TDD approach within our Scala project.
By working through this example, you'll gain practical experience with TDD principles and develop the calculateTotal
function in a way that showcases how TDD fosters code quality and robustness.
Let's begin by writing tests for a function named calculateTotal
(from the Cart
class, which we'll explore soon), designed to compute the total price of items in a shopping cart. This is where you engage with the Red phase: Write a failing test.
When crafting methods that operate over collections, it’s important to determine the interface first from a test perspective. Here's one way we might write this in Scala.
- We know we want a method called
calculateTotal
, so our test uses it. - For now, we know that we want an empty list as input to return 0, so we can write that test.
- Using
assert
ensures clarity and simplicity in checking test results.
Upon running these tests, you’ll see they fail, marking the Red phase and defining the next steps for development.
Now, let's move to the Green phase, where we implement the minimal code required to pass these tests.
Here’s the calculateTotal
method in a new Cart
class:
- The
calculateTotal
method takes aList
ofCartItem
objects and simply returns0.0
. - This minimal implementation ensures the first test passes, aligning with the requirements for an empty cart.
Running the test suite will now confirm that all tests pass, demonstrating that our function satisfies the initial condition.
Next, we refine our approach to handle specific CartItem
instances. We'll introduce name
, price
, and quantity
attributes within the CartItem
class. The total will be price * quantity
. Let's write a new test for this:
This test defines the expected behavior for a single item, which will initially fail, guiding us to the Green phase.
Now we need to think about how to make these tests pass. What is the minimum necessary to get this to pass?
- If the list is non-empty, the method calculates the total for the first item (
price * quantity
). - Otherwise, it returns
0.0
.
Running the tests again will confirm that both tests now pass, marking us Green!
Now we ask ourselves: is there anything we can do to make this code better? One thing we might do is eliminate the repetition of accessing the first item in the list.
Another thing we might do is ensure each CartItem
has well-defined properties.
In the above snippet, we define the CartItem
as a case class with three necessary properties: name
, price
, and quantity
. By using a case class, the properties are immutable by default, and we also get additional benefits like value-based equality, a readable toString
, and the ability to create modified copies easily with the copy
method.
When we run the tests again, we're still green. The code is more expressive now and reflects the properties of CartItem
more clearly, so let's do the Red-Green-Refactor loop again!
The existing code works! But it won't be very useful to only use the first item. If we add one more test, we can generalize more:
This test will fail, signaling that our current implementation needs improvement.
To satisfy this test, we’ll enhance the implementation to handle multiple items:
- This implementation uses a
for
loop to iterate through all items and calculate their total price. - The
total
variable accumulates the sum, ensuring correctness.
This does the job, and we're Green again. I can't help but feel like we could have written that code a bit better. Now that we have tests that cover everything we want this function to do, let's move to the Refactor step!
When we look at the calculateTotal
function, it is clear that this is an "aggregate function." We can refactor the code to leverage Scala's functional capabilities for aggregation:
- The
map
function transforms eachCartItem
into its total price (price * quantity
). - The
sum
function aggregates these values to produce the final total.
I like this code a lot better! Best of all, our tests tell us that we're still Green! We've successfully refactored the code.
Throughout this lesson, we emphasized writing tests before implementation and adhering to the Red-Green-Refactor cycle. Here's a summary of our process:
- Red Phase: We started with failing tests, defining behavior like returning 0 for an empty cart or computing the total for specific
CartItem
examples. - Green Phase: We implemented minimal solutions to ensure tests passed, incrementally building functionality for multiple
CartItem
instances. - Refactor Phase: We enhanced code clarity and aggregation using Scala’s functional features like
map
andsum
.
By following these TDD steps, we ensured clarity, reliability, and continuous improvement in our implementation. Practice exercises will further reinforce how TDD fosters maintainable and robust software development in Scala.
