Talks

TDD on the Shoulders of Giants

Getting started with TDD is hard enough without having to also navigate a programming language barrier. Many of the best books on testing focus on very different languages like Java, making it tricky to apply their advice in Ruby, especially if you're new to testing. I'll go through the most important practices and techniques that we can pull from the testing literature and show how they can be applied in your day-to-day Ruby development. You'll learn how to make the most of testing in Ruby using the patterns, practices, and techniques that popularized TDD in the first place.

RubyConf Mini 2022

00:00:00.299 (foreign)
00:00:11.300 Welcome to 'TDD on the Shoulders of Giants.'
00:00:14.219 I'm Jared.
00:00:17.580 When I was first getting started with Ruby, I read a book called 'Growing Object-Oriented Software, Guided by Tests.' It’s by Nat Pryce and Steve Freeman. This book was really influential to me and broadly in the industry.
00:00:22.080 It laid out a style of testing that came to be known as London style, or Maucus TDD. The alternative to that style has several names, known as classical Chicago style. However, I’m going to refer to it as Detroit style in this talk.
00:00:30.359 Don't worry if you don't recognize those terms. The fact that they have so many names is probably a testament to how infrequently they are discussed.
00:00:37.740 The point is that the book laid the foundation for concepts like Behavior Driven Development, which eventually influenced tools like RSpec.
00:00:41.219 Recently at my company, Super Good, we held several book clubs where we all read this book in different groups. We explored how we could apply the insights from the book to our day-to-day Ruby practice.
00:00:45.719 I had three major takeaways from the book. First, the name of the book is quite long, so I’ll use the abbreviation 'GOOS' that we adopted in the company. Secondly, I realized that I had missed or misunderstood a lot of the wisdom in the book when I first read it.
00:00:55.380 Revisiting it after ten years of working with Ruby on Rails provided me with a different perspective. Finally, I found that some of the techniques were somewhat challenging to apply in Ruby since all the code examples in the book are written in Java.
00:01:03.420 This disparity in the languages is where the idea for this talk originated.
00:01:06.720 I want to provide you with actionable tips, strategies, and techniques from the book, translated into Ruby terms that you can use to derive greater value from your testing practice.
00:01:11.460 However, I will warn you that I'm not going to teach you London-style testing here. The book walks through a lengthy example over many chapters, and if you want to dive deeper into that, you'll need to read the book yourself.
00:01:19.200 We will also focus specifically on RSpec. While you can apply these techniques in any testing framework, RSpec has certainly been influenced by these techniques.
00:01:24.240 Occasionally, you might hear folks mention terms like 'strict TDD' or 'real TDD.' These terms can serve as red flags. TDD is not the end-all-be-all of software development techniques; it won't resolve all your design issues.
00:01:31.500 It doesn’t replace your experience or design intuition. TDD is meant to complement other design techniques in your toolkit.
00:01:36.660 The authors of 'GOOS' are very clear about this; they introduce other techniques to address problems in their working examples where TDD doesn’t offer solutions.
00:01:46.680 TDD is often simplified to Red, Green, Refactor: you write a test, run it, see it fail, write the minimum code required to make the test pass, and then refactor it until it integrates into the design of the system.
00:01:52.739 So, what’s with these different styles of TDD? I struggled to find a concise explanation of the differences between them, as there’s a plethora of conflicting information available.
00:02:01.500 I eventually stumbled upon a post on Mastodon that mentioned the distinctions: Detroit style tests are primarily refactoring tools intended to aid you in refactoring your code without typically relying on mocks.
00:02:14.760 Conversely, London style tests act as design tools. They emphasize individual objects and their interactions with collaborators, writing tests around those interactions while using mocks.
00:02:23.640 These mocks, known as test doubles, facilitate assertions about which methods are invoked and what arguments they receive in those interactions.
00:02:37.560 The post described various other styles, including one where everyone tests solely because they are instructed to do so, leading to less than ideal outcomes.
00:02:41.919 The London style exemplifies an outside-in approach to TDD, starting with an acceptance test before gradually building functionality through tests until the acceptance test passes.
00:02:52.320 During this process, you might encounter terms like acceptance tests and unit tests. Although we're not going to debate what's classified as a unit test here, understanding the broad strokes is important.
00:03:01.500 Acceptance tests check that our application behaves as intended, but they are not designed to dictate the structure, merely ensuring everything functions as expected.
00:03:14.340 These tests, being slow, won't check every branch of your features. However, they serve as a safety net. More critical applications may require tighter safety nets, while less critical ones can be softer.
00:03:29.280 Unit tests are the essence of your TDD practice in London style. They provide various benefits and serve first and foremost as design tools, allowing for stepwise component building driven by tests.
00:03:42.599 By listening to your tests, you can identify design problems. This central idea of the book could be the basis for an entire series of talks, but the core concept is that difficult tests—whether hard to write, change, or understand—signal design issues.
00:03:57.720 So if you're skeptical, reading the book may change your perspective. In organizations that prioritize testing, hard-to-write or change tests lead to complications.
00:04:06.300 The goal is to test your objects in as isolated a manner as possible. Using mocks to achieve this isolation results in tests that focus on message-passing.
00:04:19.800 These tests can scale efficiently, allowing for numerous fast-running tests without guaranteeing that your software operates correctly; this is where you can see unit tests pass while integration tests fail.
00:04:29.700 Memes about drawers that won't open, drains that don’t drain, or windows with handles blocking others highlight common frustrations in such scenarios.
00:04:37.740 Next, let’s discuss integration tests. These are positioned between unit tests and acceptance tests. They evaluate multiple units working together and their ability to deliver desired outcomes.
00:04:48.300 While TDD doesn't adhere to rigid rules, it does emphasize one important guideline: never write new functionality without a failing test.
00:05:02.760 Whether modifying an existing test or adding a new one, that’s the starting point. Ideally, you’ll make incremental changes, one test at a time.
00:05:09.840 Although this is a guiding principle, you're encouraged to utilize other design techniques as necessary. Not adhering to this rule doesn’t mean you’ve failed at TDD.
00:05:15.420 This technique is advantageous for various types of issues but may be unhelpful for others. TDD is fundamentally a design tool and won’t assist with code optimization.
00:05:29.100 In essence, London-style TDD begins from the outside, crafting acceptance tests and utilizing them to progress on high-level features. Unit tests not only build functionality but also shape design.
00:05:38.880 We focus on assessing how your objects interact with their collaborators, utilizing mocks to maintain test isolation while gradually assembling functional components.
00:05:44.520 Although there’s much more nuance in this quick overview of the technique, to learn further, I encourage you to read the book.
00:05:51.360 Early in the book, we learn that when using mock objects in our tests, we should mock their interface types and not the classes themselves.
00:06:02.700 This proves to be slightly challenging for us Ruby developers, as Ruby doesn’t have interface types. Java interfaces describe methods, return types, and arguments that a class must implement.
00:06:13.560 Mocking interface types compels you to write code compatible with those types rather than with a specific instance you might be passing in.
00:06:21.540 You can easily substitute different objects adhering to that interface later without altering your code.
00:06:33.120 This practice helps decouple you from the current structure of your application.
00:06:36.540 When I refer to the structure of your application, I mean how the objects are presently arranged, forming a web of interconnections that comprise your application.
00:06:54.600 In Ruby, we need to reflect on why this advice was given. The authors encourage us to design our programs such that we can swap objects for different ones without needing to modify our code.
00:07:06.120 Ruby's duck typing allows us to replace objects with new ones of a compatible structure, even though it lacks the security offered by interface types.
00:07:11.700 So, while we may not need to heed this advice strictly, there are still valuable lessons to learn.
00:07:20.340 Let's take an example from the e-commerce domain. When a customer orders something online, the items are generally delivered in one or more shipments.
00:07:35.640 When we dispatch a shipment from the warehouse, we usually send a notification email to the customer.
00:07:44.220 Here's a simplified code snippet that could hypothetically handle this. Our Shipment class has a 'ship' method that takes an 'EmailNotifier' instance to which it sends some shipment information.
00:07:54.300 If this were Java, we would have to define a type for the EmailNotifier, creating an interface that leads to the question of what we should name it.
00:08:05.520 When analyzing the interface, we find that it has a single method 'send_notification' that takes shipment info, leading us to realize that this is essentially a shipment notifier.
00:08:16.320 In Ruby, we can just switch the implementation without these constraints. But without interfaces, we often miss out on recognizing the need for them.
00:08:24.900 As Rubyists, we have to work harder to acknowledge these interfaces and make them explicit. When defining arguments, variables, or mocks, consider the interface of the object, not just its current role.
00:08:37.560 By doing this, you can make subtle changes uncover hidden interfaces, creating more opportunities for utilizing polymorphism and achieving testable code.
00:08:45.840 This links to concepts we've discussed in previous talks.
00:08:50.520 Moreover, if your organization transitions to sending notifications via SMS, you won’t have to rename the variable—an added benefit!
00:09:02.520 Previously, I mentioned application structure and the web of objects. The book strongly advocates building software from these tiny, focused, and decoupled objects.
00:09:16.140 You then compose and arrange these objects in various manners to construct the functionality of your application.
00:09:27.420 The book encourages us to engage in this composition at a high level, picking a single entry point within the system where these objects can be integrated.
00:09:39.300 Typically, if you’re using a framework, you want to identify a point where the framework transfers execution to your application code. In Rails, for instance, you don’t have control over how controllers are instantiated.
00:10:01.080 Requests flow through layers of framework magic before being handled by a controller action. Background jobs can serve as excellent locations for wiring all these objects together.
00:10:11.640 However, unit tests in those spaces become challenging since you can’t input mock objects easily. Yes, you may use RSpec stubbing, but that doesn’t yield extremely valuable tests.
00:10:23.520 Instead, glucose requires coverage through acceptance or integration tests that ensure the correct setup of components without testing every possible outcome.
00:10:38.520 I found this point fascinating because many teams struggle with this one. They tend to create excessive controller request specs that become sluggish or difficult to maintain.
00:10:46.920 Thus, this insight presents some leeway; we can afford to write fewer controller tests. However, in scenarios with Rails controllers, we still need to deal with HTTP at that level.
00:10:58.440 Sometimes, it’s impractical to extract logic, so while it's possible, it's often not advisable.
00:11:09.300 I previously overlooked this detail during my initial reading of the book, which explicitly recommends against fighting the framework you are using.
00:11:17.880 If a framework expects a specific approach, diverging from that will only generate unnecessary friction. Instead, embrace the advantages your chosen framework offers.
00:11:30.300 There’s a convention for structuring tests known as Arrange, Act, Assert—sometimes referred to as Given, When, Then.
00:11:43.800 This methodology segments your test code by function: you group the setup, execute the code under test, and then make assertions regarding the outcome.
00:11:56.520 'GOOS' isn’t the sole book promoting this method, but it’s a nice convention that facilitates navigation and comprehension within tests.
00:12:05.460 It also aids in detecting potential test smells. For instance, excessive setup code might indicate an object that’s hard to utilize, while too much verification hints at excess responsibilities.
00:12:12.300 Some testing frameworks make establishing such tests a bit tricky, particularly when verifying mocks, but RSpec offers clever solutions.
00:12:30.480 Consider this hypothetical test for the code we've discussed earlier. It begins by setting up a double for the shipment notifier at the top, establishing an expectation about the messages sent.
00:12:44.520 However, it proves potentially troublesome if the expectation is defined in a before block, as this would cause all tests to fail if that expectation fails.
00:12:56.520 Instead, configure messages directly on the shipment notifier, making this a command rather than assessing the return value.
00:13:02.640 You should transfer the expectation to the appropriate test, maintaining the integrity of expectations while allowing other tests to succeed, even in the event of a failure.
00:13:11.460 This organization follows our convention and alleviates one of the annoying points that new RSpec users often encounter. It’s confusing to assert something has been invoked before it's actually executed.
00:13:29.460 Now let’s shift gears and discuss values—the kind not imposed by your company on retreats!
00:13:31.500 I'm referring to literal values like 0, 312, or false. Values are essential in testing outcomes of simple computations. Admittedly, applications involve more intricate operations.
00:13:43.500 Value objects, on the other hand, represent values without significant identity. They often embody more complex data, such as money.
00:13:57.300 Money isn’t merely a numeric value; it entails a currency and can have constraints on usage. In Ruby, there's a library called 'Ruby Money' that facilitates working with monetary amounts.
00:14:09.480 If you have two ten-dollar amounts, they can be interchanged. In the realm of crypto, they are fungible. The essence is that these values lack a concrete identity.
00:14:21.240 Ruby is equipped with various useful value types, such as dates and ranges. We've seen some in recent talks, but you can also create your own to accommodate your application’s requirements.
00:14:32.340 For example, Joel demonstrated crafting month objects when standard date objects weren’t sufficient.
00:14:48.300 In e-commerce, I frequently work with variables like money, prices, lead times, and inventory counts—all of which can be characterized as values.
00:14:59.280 By structuring these collections of data into objects, we can develop more expressive tests, enhancing both the clarity of tests and the application's code itself.
00:15:13.080 When executed properly, this approach facilitates higher-level abstraction while minimizing intricacies, especially surrounding data handling.
00:15:22.920 So how do we accomplish this in Ruby? We can create structs. For instance, we can define a struct with 'red,' 'green,' and 'blue' members.
00:15:31.920 Structs inherently support equality, thus functioning as effective value objects by default.
00:15:38.520 You can enhance them with methods, such as a method to darken the color. Importantly, this change returns a new object without modifying the existing one—consistently upholding immutability principles.
00:15:49.440 In the previous talk, we discussed that mutability could lead to chaos. Imagine being able to change the number two into three; it would be complete disorder!
00:16:04.680 Structs aren’t without drawbacks; they allow members to be altered, which can negate the immutability we desire.
00:16:11.520 Fortunately, Ruby 3.2 introduces a new feature: data classes. Defining a data class is similar to creating a struct but allows flexibility—, you can utilize either positional or keyword arguments.
00:16:20.460 Data classes retain the same quality features as structs and allow for defining custom methods, pattern matching, and other features, but you cannot reassign attributes.
00:16:31.440 Thus, value objects enable you to build more expressive code at higher abstraction levels, an area where Ruby's capabilities are flourishing.
00:16:39.840 Pay attention to discover moments to employ them. When you note clusters of data being passed around, it’s time to ask if it warrants a value object.
00:16:43.860 If it does, wrap it in a struct or custom class, or, if lucky enough to be using Ruby 3.2, a data object, and refactor your application to leverage these.
00:16:53.640 In your tests, there's no need to mock these values. Mocks are appropriate for isolating interactions between different objects, while values merely encapsulate data.
00:17:02.760 Focus on producing new values stemming from that data, just like you wouldn’t mock the integer three.
00:17:09.300 If you find yourself frequently using values in tests or if setup becomes cumbersome, consider using Factory Bot for value-creating factories.
00:17:18.240 Factory Bot isn't limited to creating database records; it can generate any object type. If it enhances your tests’ expressiveness and readability, utilize it!
00:17:26.520 Many languages lack the flexible testing tools that Ruby provides. One notable reference is Michael Feathers' 'Working Effectively with Legacy Code.'
00:17:33.600 This book addresses how to bring untested code—termed legacy code—into a testing environment, which can be complex, depending on your tools and language.
00:17:45.720 In contrast, Ruby’s nature, alongside tools like RSpec, makes testing more manageable for Rubyists.
00:17:52.620 You may have encountered tests structured this way, heavily reliant on stubbing. It becomes cumbersome to allow any instance of those dependencies.
00:18:03.960 Tests can rapidly become brittle, locking you into the current architecture of your system. Any change to these dependencies necessitates a change in the test.
00:18:14.760 This scenario runs contrary to our objectives. If you often need to stub methods on globals or entire class instances, it indicates that your components require dependency refactoring.
00:18:20.200 Before you proceed to stubbing methods or utilizing the flexibility of Ruby’s testing tools to navigate around these dependencies, assess whether these dependencies need to be loosened.
00:18:30.640 Our tools may offer us ways to avoid design problems, but they're not the ultimate solution. Use everything available if a class or module goes untested and requires coverage.
00:18:40.640 TDD is a design tool. If you encounter existing code with testing complications, embrace those complications while seeking opportunities to enhance the code.
00:18:50.600 I adhere to an extreme programming philosophy: when doing something, I aim to extract maximum value from it. This idea extends to testing as well.
00:19:01.900 The value of tests comes to light when they fail. Green tests indicate that no problems have been detected, but they don't imply everything is functional.
00:19:12.920 Thus, the red and green steps aim to illuminate failures that tests are intended to unveil. Red tests offer detailed insights—providing explicit information on how they failed.
00:19:23.240 With RSpec's versatility, you can enhance failure information with custom matchers which create expressive tests; a technique the book advocates strongly.
00:19:38.300 You can also append helpful messages to any matcher failures. I recently encountered a test that failed, but the message was very clear about the cause.
00:19:50.560 This practice reinforces the notion of clarity in failure messages, allowing for easier tracking of issues and resolution.
00:20:01.020 While most assertions do not need detailed messages—thanks to RSpec’s clear output—certain assertions might not inherently communicate your intentions.
00:20:12.360 This is where you can integrate custom failure messages. Additionally, it’s vital to select RSpec matchers that appropriately represent what you're testing.
00:20:27.600 Avoid overly strict assertions, as they can lead to false test failures. RSpec also offers matchers for comparing collections without implying order.
00:20:40.560 Keep in mind that you shouldn't strive for exhaustive tests; only assert those elements that matter to your objects and their collaborators.
00:20:52.680 Further, maintaining the brevity of your tests is advisable. Small tests tend to be clearer and easier to diagnose.
00:21:04.680 Invest time into creating context and descriptions in your examples. Avoid vague assertions like 'it returns the correct value' without stating what that value is.
00:21:15.720 Lastly, when a test fails, it’s crucial for the next developer to comprehend it easily. Complicated tests filled with unnecessary features can hinder understanding.
00:21:24.900 Assist that developer by writing tests you would feel comfortable reading.
00:21:37.860 Ruby offers robust testing tools tailored to an object model that harmoniously complements TDD. I’ve only scratched the surface of the valuable principles to glean from previous object-oriented programmers.
00:21:54.720 However, my hope is that some of these concepts will boost your testing practices. Remember to focus on object interfaces, avoid overly ambitious testing, work harmoniously with frameworks, and create clear, concise tests.
00:22:05.520 Leverage value objects effectively, maximizing RSpec’s features, and design tests that yield precise, valuable feedback.
00:22:18.840 In closing, I'm Jared. You can find me online.
00:22:21.960 Please connect with me on platforms like Twitter, Mastodon, or my company’s website.
00:22:35.160 Feel free to approach me after the talk for any questions; I’m always eager to chat. Thank you!