TDD. So pleasure. So easy.

Test Driven Development. A Practice of writing the tests before the implementation. Much has been debated about it. Yet, I’m still amazed by how much code is written daily without tests. Every time I see it, I wonder — how is it possible? Why would anyone do something like this?

When describing TDD to older programmers, I often hear, “Of course. How else could you program?”– Kent Beck about rediscovering TDD

Kent Beck rediscovered TDD as part of Extreme Programming practices in 1999. I started working with Ruby on Rails in 2015 and I’ve struggled with TDD ever since. There was a time in my career when I recommended writing tests only after the implementation. I even advocated for writing no tests at all for some pieces of software!

I’ve learned the hard way to appreciate those tests. Even harder, I’ve also learned that creating tests before the code is easier; hence, I became a TDD advocate. Here are four common problems I see people (including myself) struggle with; vexing their testing experience, and putting them on the I’ll add the tests later path.

Examples

I’m using Ruby on Rails examples to illustrate this article, but you should have no problem using the concepts in any other language or framework.

I’ll implement a service that imports transactions from a CSV file into an accounting app. Each Transaction requires a title and amount.

1. Starting with too many tests

Let’s name the service ImportTransactionsFromCsv and implement it with the TDD approach. The service should receive a path to a CSV file, read it, and create a Transaction record for every row. Invalid entries ought to be skipped, without causing the service to fail. It should return the number of added and skipped transactions.

A common TDD approach for such a service is to start by converting all requirements into test cases.

require “rails_helper”

describe ImportTransactionsFromCsv do
describe “.call” do
subject(:call) { described_class.call(csv_path) }

context “when transaction data is valid” do
let(:csv_path) { file_fixture(“valid.csv”) }

it “creates Transaction record” do
expect { call }.to change { Transaction.count }.by(3)
end

it “returns hash with success and failure amounts” do
expect(call).to eq({
created: 3, skipped: 0
})
end
end

context “when transaction data is invalid” do
let(:csv_path) { file_fixture(“invalid.csv”) }

it “skips invalid rows” do
expect { call }.to change { Transaction.count }.by(0)
end

it “returns hash with created and skipped amounts” do
expect(call).to eq({
created: 0, skipped: 3
})
end
end
end
end

That’s a lot of tests to figure out in the beginning. Sure, you can get that first version AI-generated. It’s still a bit tricky at this moment: it’s hard to foresee how many examples you’ll need, and how to structure them best to keep them DRY and tidy. How many edge cases may come up, and what can go wrong with the implementation?

Are you sure you need exactly these three cases? Can you figure out the best contexts and examples structure now? What if the idea of having two separate CSV fixtures turns out to be a bad idea?

TDD makes me change too many tests, too often. TDD sucks.

Oh. Really?

2. Too much time spent on test/code phases

Let’s implement the code based on our tests.

class ImportTransactionsFromCsv
def self.call(csv_path)
created, skipped = 0 , 0

CSV.foreach(csv_path, headers: false) do |row|
transaction = Transaction.create(
title: row[0],
amount: row[1] )

if transaction.persisted?
created += 1
else
skipped += 1
end
end

{ created: created, skipped: skipped }
end
end

Save the file, run the tests, and fix what’s wrong. Eventually, open the PR, get the approval, merge, and deploy. Done.

This is a small example, so maybe you got it right the first time. I didn’t. I had to fix not only my code, but also the tests (I made some syntax errors, and I missed the require statement).

It’s a common approach to divide the work into two phases:

1) Write many, many tests first

One starts by turning all the business requirements mentioned in the task into test cases. This can be done manually, or by asking AI-companion to create test cases. It’s a cognitively demanding task, because you need to foresee a lot of stuff that can possibly happen.

And because the tests are code too, it requires writing quite a lot of code that can’t be tested. You have no idea if your tests have errors in the setup until you run them against some implementation..

2) Then write the implementation

Having the tests done we jump to implementation. This is where problems usually arise. As we move on with the code we figure out the tests are having errors. So we need to fix the tests written previously.

Get into that for too long and it’s getting hard to know if the tests are broken, or the code. Soon enough this leads to code-driven-development where we test the code by using it, and adding tests only after that.

The worse comes when we realise the feature changed so much that some test cases makes no sense anymore, and we spend more time on removing and rearranging examples to follow up the code changes.

Better approach

TDD is like a ping-pong game: alternating between writing tests and writing code, in short cycles for instant feedback. Let’s implement the same feature again; with a quick test-code loop.

What is the smallest thing I know for sure to start with?

I’ll need a class! Let’s write a test for it.

# test
require “rails_helper”

describe ImportTransactionsFromCsv do
end

That’s it. Make it pass now.

# code
class ImportTransactionsFromCsv

end

What’s the next thing I can think of?

I’ll need to call it, with a path to the CSV file.

# test
require “rails_helper”

describe ImportTransactionsFromCsv do
subject(:call) { described_class.call(csv_path) }

let(:csv_path) { file_fixture(“transactions.csv”) }

it do
call
end

end

Create transactions.csv fixture. Leave it empty. That’s enough; make the test green.

# code
class ImportTransactionsFromCsv
def self.call(csv_path)

end
end

What’s next? This should create valid transactions. Start by adding a line to transactions.csv fixture.

# fixtures/files/transactions.csv
first valid transaction,100

Extend the test.

# test
require “rails_helper”

describe ImportTransactionsFromCsv do
subject(:call) { described_class.call(csv_path) }

let(:csv_path) { file_fixture(“transactions.csv”) }

it do
expect { call }.to change { Transaction.count }.by(1)
end
end

Make it pass.

# code
class ImportTransactionsFromCsv
def self.call(csv_path)
CSV.foreach(csv_path, headers: false) do |row|
Transaction.create(title: “broken”, amount: row[1])
end
end
end

The tests pass now but something looks off here. Let’s make sure transactions are created with valid attributes.

# test
require “rails_helper”

describe ImportTransactionsFromCsv do
subject(:call) { described_class.call(csv_path) }

let(:csv_path) { file_fixture(“transactions.csv”) }

it do
expect { call }.to change { Transaction.count }.by(1)
expect(Transaction.last).to have_attributes({
title: “first valid transaction”,
amount: 100
})
end
end

Fix the code.

# code
class ImportTransactionsFromCsv
def self.call(csv_path)
CSV.foreach(csv_path, headers: false) do |row|
Transaction.create(title: row[0], amount: row[1])
end
end
end

What about invalid transactions? Let’s add such one to the fixture file.

# transactions.csv
first valid transaction,100
second with missing amount,

The test now will still pass because .create returns false for invalid records. The loop goes on, the entry is ignored and we still create only one transaction. It’s confusing. Let’s fix the return value to make it visible, and set some descriptive example message.

# test
require “rails_helper”

describe ImportTransactionsFromCsv do
subject(:call) { described_class.call(csv_path) }

let(:csv_path) { file_fixture(“transactions.csv”) }

it “creates only valid transactions” do
expect { call }.to change { Transaction.count }.by(1)
expect(Transaction.last).to have_attributes({
title: “first valid transaction”,
amount: 100
})
end

it “returns a hash with created and skipped counts” do
expect(call).to match({
created: 1,
skipped: 1
})
end
end

Make it pass.

# code
class ImportTransactionsFromCsv
def self.call(csv_path)
created, skipped = 0 , 0

CSV.foreach(csv_path, headers: false) do |row|
transaction = Transaction.create(
title: row[0],
amount: row[1] )

if transaction.persisted?
created += 1
else
skipped += 1
end
end

{ created: created, skipped: skipped }
end
end

That’s it. I arrived at the same implementation but with half the tests. Sure, I could’ve thought to have a single transactions.csv fixture when I worked with the first approach. But neither myself nor GPT had that idea.

Benefits

I’m always only a little bit ahead of working code. Debugging becomes dumb-simple.Notice how fewer test cases I have at the end, while still having the same coverage. Or even better, because in the first try I didn’t test the value of created Transaction attributes.I need to think much less. I can focus on a single case at a time and don’t worry about what if that next thing is invalid?. Less thinking = fewer errors to fix.I rarely find myself needing to change a lot of tests when I discover some edge case. Sure, I may need to tweak something here and there, but as long as my code respects O from SOLID it’s an exceptional situation.I noticed AI-generated code is usually better when created in short snippets that pass the test.

3. Missing auto-test in development

The above flow is impossible if you need to run the tests manually every time you change the code or test. I find it hard to believe rspec doesn’t come with –watch (or similar) behavior out-of-the-box to run the tests automatically when files change.

Luckily, we have guard and guard-rspec gems. Unfortunately, both gems seem to be abandoned. The first has the last commit from Dec 26, 2022, and the second one from Sep 15, 2016. To make the experience pleasant you’ll want to add spring-commands-rspec gem (if you use spring gem), which has the last commit made on March 2, 2015.

To my surprise, this setup works with the most recent version of Rails (7.x) and Ruby (3.x), following the setup instructions in guard-rspec Readme file. This is, most likely, the only software I’ve ever seen that reached the we’re done here status.

Instant feedback on file saveAdd guard, guard-rspec, and spring-commands-rspec gems# Gemfile
group :development do
gem “spring”
gem “spring-commands-rspec”
gem “guard”, require: false
gem “guard-rspec”, require: false
endInstall gems, and generate Guardfile$ bundle install
$ bundle exec guard init rspecUpdate Guardfile to use spring for running tests# Guardfile
guard :rspec, cmd: ‘spring rspec’ do
# …
endRun guard in the terminal, and let it run the specs on file changes.$ bundle exec guardRed first, green in seconds

4. Negotiating tests with non-developers

Would you ever tell a designer how to name layers in Figma? Ask the Project Manager to have more meetings? Negotiate animations on sales slides? It’s none of your business.

Then, why would you ever ask them if or when to write the tests to your code? It’s none of their business.

But we’re late with the project and must speed up the work.

Congratulations, you’ve done a poor job with the estimations. Or maybe the scope has changed. Maybe both. It happens, nothing new in the tech world.

OK, I agree. I’ll skip those tests just for this milestone and add them in the next PR immediately after the deployment.

Yeah, sure… Everybody does…

Let’s get back to the CSV example. How would you develop it without writing tests?

You’d need to have a CSV file with transactions. Then, you’d write some code, and execute it via rails console. What if it doesn’t work? You’d fix the code and call it again. How is this saving time versus writing tests? How do you ensure the code satisfies all the requirements after all the changes? Can you even remember all the cases after a day or two of development?

You may skip the automation. But you can’t escape the testing.

Responsibility

Even worse than wasting time by skipping automation is pushing responsibility for the quality. Let’s say the Product Owner agrees to skip the tests. You merge the PRs, QA does a heroic testing round, and the Friday celebration begins.

On Monday a customer comes with a complaint. They have invalid transactions in the system… Wait, what? How? It was tested hundreds of times!

How did it happen? — stakeholders askYou agreed we can skip the tests — developer replies

An entire book of bad, terrible things could be written based on only these two lines of conversation. Yet, I can’t count how many times I’ve heard it.

It’s ours, developers, responsibility to deliver not only working software, but also well-crafted software (manifesto for software craftsmanship). Don’t let the business mess it up. You’ll be late with your work plenty of times. Stakeholders will push you to deliver faster. In those situations there’ll be things you can negotiate, and solutions you can find. Sacrificing the quality should not be one of them.

The end

Test Driven Development (TDD) is a valuable tool in the development toolbox. Despite its benefits, a lot of developers struggle with common challenges. We try to write too many tests too early and then find ourselves changing those tests over and over again to catch up with implementation added in large batches, making it harder to turn tests green.

Frustrated, we describe tests as a tool that slows us down and get business approval to skip them, shirking the responsibility of setting up proper testing tools. It doesn’t have to be like this. TDD can be pleasant. Write your tests in small batches, don’t overthink them, and make sure the tests run every time you save a file. Soon enough, you won’t understand how it’s even possible to create code without tests.

You can skip the automation, but you won’t escape testing. Don’t be an asshole. Write the tests.

I hope you enjoyed this piece. If so, make sure to hit that Follow button. Thank you 🙏

4 common obstacles to a pleasant TDD experience was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.

​ Level Up Coding – Medium

about Infinite Loop Digital

We support businesses by identifying requirements and helping clients integrate AI seamlessly into their operations.

Gartner
Gartner Digital Workplace Summit Generative Al

GenAI sessions:

  • 4 Use Cases for Generative AI and ChatGPT in the Digital Workplace
  • How the Power of Generative AI Will Transform Knowledge Management
  • The Perils and Promises of Microsoft 365 Copilot
  • How to Be the Generative AI Champion Your CIO and Organization Need
  • How to Shift Organizational Culture Today to Embrace Generative AI Tomorrow
  • Mitigate the Risks of Generative AI by Enhancing Your Information Governance
  • Cultivate Essential Skills for Collaborating With Artificial Intelligence
  • Ask the Expert: Microsoft 365 Copilot
  • Generative AI Across Digital Workplace Markets
10 – 11 June 2024

London, U.K.