Episode 29
Why Is RSpec So Hard? (Part I)
October 11th, 2023
46 mins 20 secs
About this Episode
Panelists discuss testing with RSpec, focusing on issues like mixed idioms and non-idiomatic code. They explore the importance of following the framework's conventions and using easily understood exemplary data. The concept of cognitive load is introduced, likening it to Newton's law of gravity applied to code, and they emphasize locality and scope in defining variables.
The conversation also delves into potential disagreements regarding overriding values with "let," but they find common ground! They advocate for defining variables as locally as possible and avoiding unnecessary complexity. The balance between readability and idiomatic practices is explored, with both agreeing that meaningful setup should be placed at the minimum distance necessary.
In closing, Dave teases a future topic about the difference between duplication and repetition, challenging the conventional wisdom of DRY (Don't Repeat Yourself) in unit tests. Stay tuned for Part II!
Transcript:
MIKE: Hello, and welcome to another episode of the Acima Development Podcast. We've got kind of a big crew here today, where it's myself, Mike, who will be hosting today. We've also got Ramses, Dave, Eddy, Justin, Sergio, and Freedom [SP] with us today.
We are going to be talking about unit testing. Very specifically, let me give a little context here. I want to approach this in kind of two ways. We're going to be talking about a specific unit test framework that we use a lot in Ruby called RSpec. Yeah, even if you're not a Rubyist [laughs], even if you don't use this tool, I think a lot of what we talk about will be applicable outside of this specific framework that we're going to be talking about.
But there are a lot of people who, particularly getting started with RSpec, what is going on here? If you're that person, listen on. We'll be talking about that. If you're not that person, again, I think that a lot of what we'll be talking about are kind of general principles that apply to unit testing, regardless of framework.
I'm going to talk a little, lead out a little bit here, to kind of start the discussion talking about RSpec. I'd say that most people who first look at it kind of scratch their heads a little bit. And, like, well, I think I know what's going on here. And it will copy another file and then just tweak it, which usually works pretty well. The problem with that is you end up with kind of these lineages of copied code [laughs] that diverge from each other and kind of naturally evolve over time and diverge from each other in ways that are sometimes unhealthy.
Sometimes, you end up with something introduced in there that really shouldn't have been in there that was either meaningless or problematic and ends up kind of poisoning the descendants of that file as they get copied over and over again. I'm talking about RSpec. This is true of code in general if people kind of start with a template and copy it over. That happens especially in unit tests because your test has some boilerplate to approach the problem of, you know, validating code.
You know, at some point, you have to have some sort of assertions. You know, you have to test some logic that happened. And it's more likely to be copied, you know, to be coded by copy, and then code in other places. Because in other places, you might be starting, like, well, I'm going to approach a new problem. There is some degree of copying that goes on there.
But here, it's especially tempting to just copy something wholesale and make some minor tweaks. Because you're like, well, yeah, well, I'm just testing this slightly different logic here that was similar to something over there. And I don't do this as much as I write code. Maybe, you know, maybe your company hasn't done much unit testing yet. And so, you know, I'm not as familiar with it. I'm just going to start from that.
And I've seen many times this divergence of testing as it goes through this copying lineage and ends up evolving over time. And you can even, like, kind of identify when the tests were written because, like, oh yeah, this was vintage, you know, 2018. And I think we can do better than that. We can do better. As an industry and a whole, we can do better with RSpec because it's actually a really great tool that I love. It took me a while to get my head wrapped around what's going on. I think it's because it's simpler than I expected.
And I'm going to start by making a few assertions from my experience. And I say it's assertions. I think that's fair. I'm going to say that we should go about this problem in this way. And that will prompt some rejection of my assertions from our panel here or allow us to dig deeper into how some of those apply.
Here's what I'm going to start with. I'm going to talk about this. Again, we're talking about RSpec, very specifically. RSpec is written in Ruby and really takes advantage of a feature of Ruby that's called blocks. For those who are not Rubyists, they're just a little special syntax for anonymous functions. Ruby is really based around that. And RSpec is designed to create specifications. So, when you run it, it's not strictly unit testing. It also produces documentation of your code. You're specifying what your code should do.
And well-written RSpec will read like documentation, so you can use it to look and generate documentation for your code and understand what it's doing. And it does that by using the rules of context in the language. What is the variable scope here in these blocks? And that's not something that we always are really good at thinking about. But if you think about what's really going on there, is it's just passing around some functions. It registers something somewhere. I haven't even dug into the code that much. But it's taking these anonymous functions that you pass into the specifications and registering them so that it can execute them later with some documentation, so it prints them out.
So, you think, well, I'm just going to create some code that it'll run at some time. At some point, it'll register it. It'll run it, and it's going to run it with some documentation. And if we start with that mindset, that doesn't sound too bad. Like, oh, I've just got some code that can run and print out some documentation as it does that.
And the second thing that I want to say...so I want to start by saying, well, Ruby allows you to do this, you know, it's going to create these anonymous functions, and it's going to run those. And if we remember all these dos and ends, and blocks that are going on and all this nested structure, it's really just nested calls to this anonymous function. Well, I think I can wrap my head around that. I've just got a piece of functionality that I'm going to call later.
Other thing that I want to say is that what we're doing when writing to unit test is actually relatively straightforward. And it is taking our code and identifying the branches in that code, the logical branches. Now, these may be explicit. You might have an if...else. Well, that's obviously a branch. But you may have something a little bit more subtle, where you have a return statement that happens sometimes, or you have an exception that gets raised that causes your code to branch in ways that aren't as explicit but are still happening.
And I'm going to argue that what a unit test is there to do is to test each branch of your code. And if you think about it that way, what I want to do is I just want to exercise each branch of my code; well, then the challenge you have with testing is not to try to think of every scenario but rather to just simply destructure your code into its branches and follow each one of them. And my experience has been that that approach to using RSpec, and this applies, again, not just to RSpec but unit testing in general, is extremely effective.
It leads to very clear, legible tests that print out good documentation and actually capture what we're trying to accomplish, which is we want to make sure our code does what we thought it did. And what code does is it takes a certain set of scenarios and executes on them. And if we test each branch, well, under this condition, it does this, then we've covered everything in a way that we can think about.
RSpec gives us context to represent each of those branches. And so, if we use context in that manner to represent one of those branches, then we will just have a set of nested context that follows our branches down. I think that a test that is built that way works out great.
If you're using more features of RSpec than that, you might start running into trouble. If you start trying to use something really esoteric or sophisticated, you might actually hurt yourself because you are trying to oversimplify or overcomplicate, as the case might be. But you'll make your code less hard to see at any location in your code what's going on. We can get into that later.
I'm going to start with my high-level assertions. Dave, who's here, has actually given a conference talk before on this topic. I expect him to have some feedback. I'm looking forward to the feedback. We've also got Justin here, who is not very familiar with RSpec, and I want him to pepper me with questions because you're not very familiar with RSpec, but you've worked with other testing frameworks. What are your responses to what I've said so far?
JUSTIN: Sometimes, it's hard to look into it without comparing it to other ones, right? I'm looking at it from a perspective of JUnit tests from the Java world that I came in. I don't know if we want to go into that yet, like comparing directly. But that's the way I would look at it was, like, okay, how easy is it to mock? Is it as easy as Mockito or other mock frameworks that I've used? You know, is the response formatted well? Does it work with all my CI/CD tools? You know, all those sorts of things are very important to me.
I like the fact that you can look at it and read it. That's really key to me also. Even though if you aren't familiar with it, you can read it like English and see, oh okay, this is what we're testing. We're testing this specific path in my code. And I can tell that just from reading the thing.
And then finally, is, how fast can I pop these out? Like, I want to test all the branches, just to make sure that everything is correct. And the other day, when we were chatting for the book club, I thought that was excellent for me looking at those examples. But yeah, I just wanted to kind of dive deeper into those things.
MIKE: Great. You hit something important there. You know, and I used JUnit as well years past. A lot of this is tool-independent. You also pointed out something interesting about the legibility. Again, RSpec...it's right there in the name, your specification. By kind of flipping the way you think about the problem from, well, what are my list of assertions? To saying, well, what am I specifying here? What am I documenting? You end up with tests that read a little differently.
Instead of thinking about the problem in terms of a list of assertions, you can think about the problem in terms of those logical branches and have the specifications read that way. And I think that that is actually a big deal. And the people who've embraced it, I think, really love the tool, really love RSpec for that reason. It's because it maps very naturally to healthy tests that generate this kind of good documentation.
But if you're trying to think about it as list of assertions, which is kind of a natural way to think about things—it's often done that way—you can end up in kind of a weird mental spot where it's hard to think about the problem, and you end up with a test that's somewhere in between that is maybe not as healthy as it could be.
DAVE: What Mike said about the concepts are similar; this is going to sound like a comment from The Matrix. I don't see the code anymore. I just see block context expectation. But you can see this in any language. Like, in JavaScript, there's a testing library called Jasmine that is literally a port of RSpec. You can tell that whoever wrote this went, RSpec has a really cool concept. I'm going to steal it.
And then, there's another testing gem in Ruby (It's actually not even a gem. It's in Ruby Core now.) called Minitest. It is RSpec with all the magic stripped away. It's literally just define a function and run the thing. And when you're like, oh, well, how do I do a context? And then, Minitest is just like, just write a function. Well, how do I let a variable? Just write a function. In RSpec, we can say things like, let database, and then you give it a tiny block that says, you know, Postgres adapter dot new, username, password, database name, right? And that's all it is.
And you're like, well, how do I let the database in Minitest? Well, you just write def database, Postgres connection, new username thing. Although later on the call, yeah, I'm probably going to argue for…you could probably just put Postgres adapter dot new, username, password, database in every single test, probably, maybe not. You know, you might not want that. But depending on some of the context, if that's an important part of the detail, you should put that in every test.
But the idea is the same across the board. Like, if you go look at Jasmine, there's a function called describe and a function called it, and they're the same thing. They take a string describing the thing you want to test. And then, they take an anonymous function in JavaScript. That's your Ruby block. And inside of it, you can write an expectation. And that's the entire Jasmine library. It's just describe, it, and expect, which is really kind of cool.
Another common parallel is, like, I've seen people that will organize their test suite where they will make a subdirectory for each object that they want to test. And then, in that subdirectory, they'll describe a condition as another subdirectory. So, you might have a database connector. It might be literally a subdirectory in your testing directory. And then, you might say, like, anonymous users or unauthorized users might be your next directory.
And then, you could have five different file names that describe a different case or a different branch of how things go down in that context. And then, you open up the test file, and there's only, like, two methods in it. And you're like, this is really tiny and, like, that's pretty spread out. Similarly, you might have an RSpec file that has that entire directory tree all in one file because it's just context. You know, describe the database, context when the user is anonymous, context when the user is illegal. And then, there's all the things.
And so, you might have, you know, a 2,500-line RSpec file that describes what the other test library did with, you know, a whole fleet of test files. But it's the same idea. We're describing things. We're grouping up collections of behavior. And then, we are describing, or asserting, or specifying, or testing and expecting that it's going to do something.
MIKE: The thing that you mentioned, repeating the setup, I think that that's something that we should really dig into. There's a tricky balance there. And there's a style that I've come to favor, and I'm going to say why I favor it. But there are some constraints to that.
DAVE: There's a lovely catchphrase that I came up with. I have refined that catchphrase because I found it unsatisfying eventually. I want to hear your thing because I'm betting that you ran into the same unsatisfying thing that I did. And I'm hoping that you came up with a completely different and interesting approach to it because then I will learn a very cool thing. But I have refined that idea a little bit, but I want to hear yours.
MIKE: Okay, here's what I'll start with: I think that if your setup is not in the same file, that makes it hard to find, and you've done something wrong. Every rule, in general, has an exception. And so, I'm sure there are exceptions to this. But as a general rule, I'm going to argue that the file level is an excellent place to localize or to don't repeat yourself, you know, to DRY things up. And all of your setup should be within the same file.
Now, other people might have some style difference. And they say, well, maybe it should be within a certain describe block within the test for a single method or something similar. Those are little nuances. And they may actually are not even in disagreement because they probably won't have much overlap between method tests.
There is a real problem, and I think that's what you were alluding to Dave, is that if you have setups somewhere far from where you're at, it makes it really hard to work with your test because now you have something shared with something else. And they're going to fight with each other [laughs], and not only fight with each other but be far away. And you have to go dig around somewhere else to find it and hope that if you change that setup, that you're not breaking everybody else's stuff.
DAVE: Yes.
MIKE: And that's terrible. So, here's what I like to do. I like to set up a happy path as default configuration. In RSpec, I would do this with let blocks, which, again, allows you to define a little, anonymous function that will just define essentially, like, a variable. Think about it like a variable, but it'll be executed a little bit different scope and has some caching associated with it. I like to do all of my setup for a happy path.
In my context, I don't respecify the happy path information; rather, I specify what is unique to that context. So, for example, somebody forgot to pass in a username context. I would override my happy path username with a nil username. Then, within that context, my only setup is going to be, well, let username be nil because that's how it differs from the happy path.
And then, it's very obvious, within that context, what that context is testing that's different from the default case. It allows then my assertions. In RSpec, the specification syntax is really nice. It's the function called it. So, you can read it as it should work, or it should raise an exception, which reads, again, really nicely like English. But it allows my context to be a little more than the specification of what it diverges from the happy path. And my assertion means it's really easy to tell what's going on.
If I want to go see all of my default setup, well, I just scroll up to the top of the file or to the top of the block for the function I'm describing. So, it's really easy because I know that all of the happy path setup is at the top of...just scroll the top of what I'm testing. It's not far; it's easy to find. And it's all...by default, it's going to show everything as it should be.
And then, in my context, we'll talk about, you know, all these branches. And a lot of times, you're branching is around validation and unhappy paths. And then, down at kind of the bottom level, I'm going to go into deeper and deeper nesting of, you know, this thing was wrong. This thing was right. And as I go all the way down, I'm going to remove some of these bad conditions and end up with something that just works. And that means that at any level, I'm only specifying what differs from everything else.
So I don't have to think about other setup. I'm trying to accomplish the thing you're talking about is, like, I don't want to have to go somewhere else to find what I need to think about. And I think that I can accomplish that by, again, setting up the happy path by default and each context showing something that's different. That isn't the only way to do it.
The tests that I've seen that follow this pattern, and I've seen other people follow this pattern, it's generally very well received. People say, "Wow, this is so easy to read." Again, this is not unique to me. I think it's how the framework is designed is to allow you to, you know, to specify the specifics in your context that make it specific.
DAVE: I like all of that.
EDDY: So, RSpec is still relatively new to me. Like, I've started to really get into it pretty heavily. And the thing is, is, like, when doing a bunch of research, you stick to what you know, right? Or what the codebase is doing. But then you look up other implementations online, and suddenly, you have different opinions on, like, the best way to write it, right?
For example, you mentioned that you like to set up your test with let, right? But there's something to be said about avoiding RSpec constructs like let and before and, like, sticking to plain, old, like Ruby methods and variables. So, there's something to be said about that. People argue that it even reads better, right? Since you're not applying, like, a different implementation, if that makes sense. Because on the surface, it's still Ruby.
MIKE: Well, I would maybe have a simple counterargument to that, which is, well, you could use Minitest [laughs], right? To some degree, I think it makes sense to...and I don't think this is always true because you could use obscure features of a framework that are probably going to [inaudible 18:25] anyway. And that's probably a bad idea. But I think it generally makes sense to follow the idioms of the tool that you're using. And don't try to force another idiom onto it.
If you want to go leaner than what RSpec has, to Dave's point, why not use Minitest and just use the functions all the way down and not worry about the framework? I think that that probably would make more sense. Let's not bend RSpec into something it isn't. Let's just use the tool that we've got if that's the tool that you're using.
DAVE: I have a statement that describes both of your situations as a single statement, which makes me very, very happy when I run across this. This is something that I learned from another panelist on Ruby Rogues. When somebody says this is readable, what they think they're saying is they think they're making an objective statement about readability, like, there's some quantifiable what is the readability of this passage of code? 4.12. What does that mean, right? 4.12 of what? 4.12 readability, obviously. It's what I just said.
MIKE: [laughs]
DAVE: Right? When we say this is more readable, what we really mean is this is similar to what I am familiar with. A lot of people coming from plain, old Ruby and Minitest they expect functions, right? You are in a class, and you are in a function. People who've really been soaking in RSpec, here's a thing that might kind of bake your muffin just a little tiny bit.
In RSpec, we say, you know, describe thing do, and then you're in this block, right? And we know what a block is. It's an anonymous function. No, it's not. It's a class. What? Yeah, in RSpec, we turn blocks into classes, and they all inherit from—I'm going to mess this up—like the example class, or something like that, or the context class. Don't quote me on that. I haven't looked at RSpec code in a year.
But it's a class, which means you can do something in RSpec blocks that you can't do in plain, old Ruby, and that is define a block. And then, in the middle of that block, just declare a function. Like, literally just describe database do def connection. What? You can have do and then follow it with def. You can't do that in plain, old Ruby. But in RSpec, it magically yields this anonymous class describing that test case, which now we're in this really dark, Byzantine black magic of RSpec.
There's people that, like, prefer Minitest. I don't know if this is still the case, but the source code to Minitest used to be printable on two pages. And the source code for RSpec was a document about this high, which can make it hard to wrap your head around what is the test framework doing? The reason I love RSpec is because I don't need to know what the framework is doing. Describe thing do. In my do, I'm going to use lets and its and contexts, and that's all I need. But yeah, sometimes you're like, hey, I want to describe this.
So, when we say readable, what we mean is what is familiar to me. The active bit to this that I would suggest is to recognize that if you come across something that is not readable or not familiar to you, take a moment and step back and go, is this person really just kind of clumsily mixing unimportant details with important details? Or is this person transmuting concepts into their local idiom, like the idioms or the framework? Have they translated this into this foreign language, right? So, it's not my native language, but they've translated it to this foreign language.
Oh, they have translated all of the important things of this, and they have left out all of the unimportant details? That is probably an indicator that you should bump up your style. You should sit down and go; I'm going to learn the style here. It's not readable to me. It's not familiar. But I'm going to study it until it becomes something that I'm fluent in.
But on the other hand, if somebody is yielding a block and just slapping a method in there because they don't want to use a let, even though a let really is just another anonymous function that's lazily evaluated, that's all it adds to is, if you don't call the function, it never gets evaluated on that test run. RSpec just does that for you.
If you want to get away from that overhead, yeah, jam in a def, but recognize that the people on your team that are RSpec...oh, and this is the dark side of that, which is that if you walk into a document written in French and you're like, I don't speak French, but I speak English really well, and you jam in a phrase in English, recognize that everyone else on your team might be fluent in the French, and see your English and go, "Oh, you've just pushed a really painful bit of cognitive load."
What they will say is...they won't say you've pushed a bunch of cognitive load on the rest of the team. They'll come back, and they'll go, "This isn't very readable." Oh, hoist upon our own petard.
MIKE: I think you nailed it and very consistent with what I was saying. We mix idioms; it makes it harder. And most of the time, when I've seen RSpec that is hard to work with, it has been because of those mixed idioms. It has not followed the RSpec idiom. I didn't understand it. But it was saying, well, I'm going to try to do things my own way because this is what I'm familiar with.
For example, I've seen a lot of setup that will define a before block, which is, you know, kind of some setup. It's going to be open and, hey, do this before all the tests. And they'll just define a bunch of instance variables within the class so that they're within the object that's going to be created that then can be used later. Well, yeah, you can do that. But once you've done that, it really doesn't look like RSpec anymore. You've basically just written your own test framework [laughs], and it doesn't follow the conventions of the framework we're using. And it forces everybody to bend around it.
And that happens; I don't think deliberately. People just think, well, I think I've seen something like this; let me try that. And not thinking about the fact, well, this really is just a set of anonymous functions. And if I'm doing it differently, I'm probably doing something wrong.
DAVE: That's really well said. So, winding back a little bit, I said that I had refined this idea a little bit. And then, you actually gave a different angle to it, which excites me because that's what I was hoping was a different viewpoint of this.
The thing that you talked about if, like, something is in another file, it's more painful. And what I've come up with is kind of Newton's law of gravity but for cognitive load. And cognitive load is the mass of what you're testing times the mass of the thing that you are including divided by the square of the distance between them. Okay, that's fun and fancy and math nerdy. Here's what I mean: if you are in an it, like, when I do 40 plus 2, it should be 42, or I expect it to be 42. In my it test, if I say A equals 40, B equals 2, expect A plus B to equal 42, it's all right there.
I might say, let A 40, let B 2, and I might stick that up at the top of the file. That's a little more distance. There's a little bit of cognitive load there. And there's an important reason why you should use that. But if you use it because you're just used to putting all your local variables in let, please stop because you are paying a price to put that variable into orbit, far away from your code, not far away but farther away from your code.
And if you move it to another file in the test suite, that's even farther. And if you move it into another piece of code in another gem in a different repo, forget about it. Why are you testing that? Leave it alone. Get away from it. Here's the value to this. And I'm going to switch to a litmus rubric kind of thing. Ask yourself this question...and it makes the answer feel a lot more intuitive to me. Take what you're describing and the context, right? Describing the database when an unauthorized user attempts to log in. Take that.
And now, in your head, imagine the Stack Overflow question: Hey, what happens when an authorized user tries to log into the database? Now, what goes in your test is the snippet of code you would put in the Stack Overflow answer. Now, think about that. If you say unauthorized user connecting to the database, and if this is on a Postgres forum, I'm probably going to say, Postgres adapter dot new, dot username, password, database. Because anybody just reading this webpage needs to know what kind of database class are we using? Are we using the ODBC class? Are we using the Postgres? Are we using the Pg gem? You need to show that. That's important.
And you might sit there and go; every single test on the database has got to connect to this database adapter. Yes, because somebody who comes to Stack Overflow doesn't care about the 23 other things that you are testing. They should care about the one thing that doesn't work. The old rubric I used to use was tell the whole story, and it made it really easy to put duplication into your code or to get repetitive needlessly.
So, you're going to say Postgres adapter new, username, password because somebody reading this is going to want to know where did this connector come from and what flavor is it? And then, you can have your assertion. If something is relevant to the way the test runs, and in RSpec, we might do this with a context, I will put those in a shared bit of code in the same file, like a before block or a let statement. I won't ever use a shared context, but I don't want to talk religion. That's just me being religious.
The idea...so, you've got somebody coming in, and they're like, what happens if we try to log in the database when some other thing, you know when we have seven-bit arithmetic turned off? Which, why does that matter? I don't know. There's some weird detail. The eight-bit versus seven-bit arithmetic it's important.
So, okay, it's not super important to this exact test. But you're not going to be able to sit down with a blank install of Ruby and get this test to work. You're going to need this. And you can pull that out of the test, but remember everything you pull out of the test. Imagine somebody sitting next to you reading your Stack Overflow question, and they go, "Hey, what's this?" And they point at that variable.
If it's something that you go, "Oh, it's this. Oh, it's the arithmetic base," "Oh, okay, got it." That could be in the same file. We're going to put it into orbit around this test, but low orbit, right? We're going to put it in the same file or maybe in a before block of this context, right? So, it's only up, you know, 15 lines from this bit of code. But if it's something that somebody goes, "What is this?" And you go, "Ugh, I don't even want to talk to you about that," that shouldn't be in the file. That could go away. That is something you should pull out of this thing and move it very, very far away.
You sit down, and you say, Postgres connection, blah. And then you do, like, you know, Devise or Warden login. And somebody goes, "What's this Devise thing?" "Dude, Devise is a whole Ruby gem. It's part of your system. It was part of your application setup. I don't want to get into that." If that's not working, we need to have a completely different conversation. And that might be what's broken. That might be why you came to Stack Overflow.
So, I'm going to give you just enough of a hint, which is I'm going to use this thing, but I'm not going to tell you what it is. And when you go looking for it, it's very far away. When you go far away, you start to drop context out of your brain because the cognitive load gets heavier and heavier and heavier. I argue that it increases with the square of the distance. The further away it is, the more and more and more it gets harder to hold in your brain.
If you get out two files and into another gem, you're not going to remember what you came to this little test Stack Overflow answer, right? This little test answer that you got. You're no longer talking about that. And that's good if that is what you want it to make happen to the reader. Devise is broken? We need to have a different conversation. The problem you think you're having, which is this user is logging in weirdly, no, no, we need to have a different conversation. Your Warden setup is completely broken. That's the thing that you push far away.
You need to have Ruby compiled for ARM versus Intel. That's a completely different conversation. But think about it: you need to have that before that test will pass. But that shouldn't be in this test. It's not relevant to this test. Make it far away, very, very far away, right? Literally, RSpec at a [inaudible 30:06] and you hit Enter, that should not work if your ARM versus Intel is broken, and it won't, which is great. Don't drag that into every test. Don't say, yeah, compiler settings set to..., right? It's distracting.
MIKE: I think you hit on something that I'd like to build on a little bit. You talked about some things should be far away. And it comes back to what I was mentioning kind of briefly near the beginning, which is about variable scope. I think the setup should be scoped at the minimum distance that it can be and still work. I think --
DAVE: At the minimum? Everything would be in the block, in the it block, right?
MIKE: That's interesting. And I think that the answer is no. Because if --
DAVE: Ooh, some things should be far away. Sorry, sorry, I think I just picked up your breadcrumb. Go ahead.
MIKE: If you put everything minimally far away so that you don't have to repeat ugly setup, right? A setup that is hard. Now, easy setup, something that's trivially easy to set up, like, let's say, well, I want every usage of the integer one to be shared across my whole app, and so I'm going to set up a global constant of one. So, if anybody wants to use the number one, they should use that constant. Well, that is abuse of the system. That was set up that was trivial, and you're actually making things worse by making it universal.
DAVE: Because it has nothing to do with the database login.
MIKE: It doesn't. So that meaningful, the meaningful setup, meaningful setup should correspond to its scope. And so, for example, in your example of properly configuring your database connection, that's global, probably, global for your application. And so, that should be configured at a global level. If you have, within a context, something that's going to be used in multiple different tests, then it should be defined within the scope of your context. It should be in a let block at the top of your context.
If you have something that's only going to be used within a single it block or a single block that's going to have some assertions, then that should just be in a variable defined within there because it's not going to be used anywhere else and just particularly if it wouldn't be used anywhere else. If something is obviously going to be nice to read, if you're going to be writing another test in five minutes and you're going to be reusing it, well, be nice to yourself and set it up.
But this idea of locality and of scope, I think, is vital, and it's related to scoping our variables, generally. But, you know, global scope is generally dangerous. But there are some things that should be global. And here, I think the same thing applies. In general, most things should be as local, you know, be quite local because they're only happening locally. But if you have something that's going to happen within the context, well, it should be in the context.
And if you have a wrapping context where it applies, well, define it there and define it in RSpec in a let block so that you can override the value. Here's an example: if I have a list of parameters that's going to be passed into something, you know, something that has got named parameters, say, and I've got a list of five named parameters, well, there's some setup involved in calling the constructor, right? You got to call the constructor with this set of five parameters.
And if I repeat that everywhere in the file, it's a lot of visual noise. There's some overhead that I'm going to have to fight with to say, okay, I have to dig through all of that setup in every test. So, I think it makes sense to use RSpec's feature for that, which is subject. And I could define my subject with, you know, I'm going to instantiate this class, and then maybe within my particular describe class, I'm going to call methods on it, but I'm going to do that instantiation elsewhere. But I'm going to have a let block for each of those parameter values so that if I need to change the parameters, I can easily override it anywhere.
Now, there's a place where I think that having some shared code actually makes it easier to read because I've removed visual noise. And the scope that that's in is within the scope of, well, it's really my unit test, right? Because I'm testing this shared within the class or maybe shared within the method. That is shared scope. They're using a shared thing. And that way, I only have to override what's not shared. But if there's something that's really only going to be used locally, well, yeah, it should be defined as a local variable because it doesn't apply anywhere else.
DAVE: I like that. There's a thing that you were saying that was kind of putting my hackles up. And then, I think we're actually mostly, mostly on the same page.
MIKE: [laughs]
DAVE: And that is, there's this notion of using exemplary data. Now, this is…we talked before the call about artistic versus implementation grindy details, right? That's like, Minitest, you have to write def thing. And RSpec you have to write it do. That's not art. But then you get into, what are some artistic things? These are things that could be done either way. It compiles just the same. And different people find it appealing. And exemplary data is one of these things, and I really like this.
So, let's say we've got some custom integer class, and it needs to do addition. Or, no, actually, we've got an accounting class that is going to add these things up. We don't actually care how it's doing the addition. We just care that the addition is correct. And this is the thing that I used in my talk where I said I would not use let A be 5, let B be 7, expect A plus B, and then let answer be 12. And then, expect A plus B equals C. I would never do that. And the reason why is because I have had cases where A and B were both nil, and this did not throw a nil value exception. And C was also nil.
And I'm looking at this test seeing, oh, A plus B equals C. Okay, cool, cool, cool, cool. No, your whole system is broken, and your test isn't telling you that. But if I give you a piece of code that says expect to be adding function on 5 and 7 to equal 12, you don't have to look at anything else. You immediately know. And part of this is because we all just look at 5 and 7, and we instantly know what that means. And we know those added together those are 12. Perfect. That's great. We don't have to stop and look at what it is.
A little slightly higher level, let's say you've got a user who can log into the system and a user who can't. And we're, like, showing the differences in the behavior. When you try to access your change password page, you should get, like, a 403 forbidden, right? You should be sent to the login page, not to the thing, right?
I would not do, let good user be this user; let bad user be this user. I would actually have let. I have an interloper. It wouldn't even be a let. I would have a local variable. I have an interloper, equals user dot Ivan, bad person, password illegal. And then, next line, good user equals Alice Johnson, password, you know, let me in. And then, in the test, we see Ivan cannot do this, but Alice can. Or when Alice tries to access this page, she gets this page. When Ivan tries to access this page, he gets that page. And I love that. That's this beautiful deterministic.
When the interloper comes in tries to access this page, he gets sent here. And that is correct behavior. In the same way that if you say I write a divide function and you try to divide ten by 0, the correct behavior is to burn down and sink into the swamp, right? The correct behavior is to raise a divide by zero exception. Somebody once expressed this to me visually. They had just bought a hybrid electric car. And there was this gray, big, like, six inches on the side yellow warning of a stick figure being struck by a lightning bolt while trying to put their hand inside the battery compartment. This is a pretty clear message.
But he described it in terms of an RSpec expectation. He said, if you stick your hand inside this box, you must electrocute yourself to death with 30,000 volts. And I'm like, what? The correct behavior is be electrocuted to death. Sorry, that's kind of weird content warning, but anyway. The correct behavior is to burn down, fall over, and sink into the swamp. So, specify that, right? --
MIKE: Well, what you're saying is actually very much in line with what I was saying. You're talking about the exemplary data. As I was suggesting at the beginning, in my tests, I like to define a good set of data as the default. Here's a good example. And then, my context should break that data and should be saying, this is how it's wrong, context that A, B nil, right?
DAVE: Yeah.
MIKE: And then, my assertion is going to say, it should, you know, raise an exception of whatever sort it is. That should be abundantly clear. I want my test to illustrate what happens in this scenario. In this context, when we follow this branch of code because that's a branch of code, too, right?
DAVE: Mm-hmm.
MIKE: Divide by zero; well, this should happen. Not an obvious one. You don't have an if block there, but you certainly have that branch. I want to cover each of those branches. And I want my test to be structured that way. And I like my setup to default...my setup right at the beginning to default to, you know, the happy path. Everything's good. And then, have also the tests be covering all of those other cases.
And it also makes it easy to add another one; ooh, yeah, what if somebody passes in a string [laughs] instead of an integer? And I can easily specify that context. I'd like to only have to specify that, well, A was the string hello. But I would not specify at the top of my file that there's a string called hello. That would be local, completely local to my...by passing a string test.
DAVE: Yeah. There's two bits to this, and we might be in violent disagreement. Or my answer might be we can still be friends because I believe in love the sinner, hate the sin. We're talking about, like, the distance, and I keep using this stupid law of gravity, right? What's the hardest place to keep a rocket? The answer is not on the moon or across the solar system. I mean, those are really hard places to put a rocket.
The hardest place to hold a rocket is about 500 feet off the ground. And so, for anybody listening...we should put this in the show notes. I gave a talk called Some Truths About Some Lies About Unit Testing. And, in that one, I talked about, you know, not using lets, not using these things, and the cognitive load on the distance.
MIKE: So then we're in agreement. [laughs]
DAVE: Possibly. Oh, oh, I talk about in this that we had had a codebase kind of implode where we were getting these Jira tickets, a feature request: Please make it so that the user can put favorite color on their profile, right? This is just one piece of data; add it to a user. It shouldn't be a problem. But it would break everything. And I would go to fix it, and I would find out that users were actually defined in a shared context way over here.
So, I'd go over there, and I'm like, okay, we'll add favorite color equals blue, and half the test suite would explode because they're asserting the user must have this exact interface. Or, we actually have some users coming in from this healthcare system that are expressly forbidden by law from having a color preference. So, they cannot even have favorite color on their profile. It's against the law. It's a violation of TCPA, I don't know, whatever, right? You know, some communication standard.
It's like, wait, what? You can't ask somebody what their favorite color is? No, no, no, no, no. But according to this standard, color means you're actually asking for ethnicity because it's skin color, and that's illegal. You can't do that. Oh my God, why are we talking about skin color? I just want [inaudible 40:56]; my favorite color is blue, right?
And what happened was this codebase collapsed on us. And we were getting these silly things that should have been zero story points that were taking us a week. We had a one-line code change, half a line, half a line of code, one side of an equal statement. And, you know, if X equals Y, now that's a branch. So, that half a line of code actually had a big effect later down in the code. But yeah, it was a thing within that shared context, and half of the suite needed X to be equal to Y, and half of the other suite required that X never be equal to Y.
And we came up with a way of solving it, which is we came up with this let that this [inaudible 41:38] would check locally when it was creating itself. And we would then invoke this context to go get that thing which would call back into us. Do you understand, like, the cognitive distance on this is, like, 4.21 bazillions, right? It was way too much.
What I discovered from this is that we would do context da-da-da-da, and then we'd have, like, a before block, or we'd have some lets. And the hardest test, almost as hard as understanding a shared context that calls back into some excluded examples, which are in another file, by the way, which calls into a let locally, almost as hard to track down was a let at the top of the file that says, you know, we're going to work with, you know, the addend and the augend, fancy words for two numbers you're going to add together. We're going to define these to be 5 and 7. Halfway down the file, we have a new context called, you know, incorrect math. And there we say, let B equal 7.
So, now A and B are both 7, right? So, the correct answer should be 14. So, what you do is you're like, uh, what's A? What's B? Oh, they're at the top of the file, 5 and 7. But why does this say expect the addition to be 14? Finding that before block, it's in the middle of a file, and that file is 2,000 lines long because as much as you want to be a good programmer, sometimes entropy gets away from us. It's not great. We don't like it. We shouldn't do it.
But yeah, oh man, if we're talking about, like, we define this beautiful document, and then halfway through the file, we build this overlay that, hey, I'm just going to paper over a different value for the password or paper over a different value for this variable and then the rest of that context asserts something behavior based on that, super, super painful.
MIKE: Oh, and I would agree with you strongly because they shouldn't have overwritten that unless it was context-specific.
DAVE: Yes. Yes
MIKE: And so, we're in complete agreement because the minimal rule applies.
DAVE: Yes. I love that minimum rule that you should basically say, whatever this variable is, gravity should hold it right there, not just to the same test block but to the same line of code. This was the example I used in my talk where I talked about, you know, expect 5 plus 7 to be, well, you don't need any other lines of code anywhere. You don't even need to look two lines up at the top of the it block and see the A and B being defined.
And the beauty is, is the very next test says, expect hello and world added together to be hello, world. You're like, oh, it can add strings. Like, you know that instantly from one line of code. Again, that's getting back to, like, exemplary data, which is an artistic thing. Exemplary code really falls apart when the example has something subtle about it that makes it break. You don't want to do that. Then you actually want to say no, no, no, this is invalid because the parity bit is off. Give it that name. Don't give me exemplary data. I'm like, why is that off? Oh, it's 43 characters of hexadecimal noise instead of 44. You can't see that from an exemplary data.
I will hammer one last time; imagine you are reading the Stack Overflow answer. Or, if you're older and you remember, like, MSDN, which was this thing where you could say, "Hey, how do I log into the database?" They would give you, like, seven lines of code in whatever, you know, Visual Basic, or C++, or whatever language we were working in, would give you seven lines of code. And those seven lines would compile because everything was defined, everything that you needed.
And if there was something that you needed that was special to that block of code, they would say, somewhere else, you need to put this. That's something that you could put in a before block, or a let, somewhere else. Everything else was gone, but everything that needed to be in the answer to that document to give you everything you needed to know was right there. And it's beautiful.
I will tease this. If we do another episode, there's a difference between duplication and repetition. In a unit test, you should not DRY up your code. DRY is Don't Repeat Yourself, right?
MIKE: That's another episode, but I agree with you.
DAVE: That's a whole other episode. You want to do WET, which is Write Everything Twice or thrice or, you know, whatever. Duplication is not the same thing as repetition. An example of that was those users that one of them had to have color and one of them couldn't. Those were completely different classes of objects. Why were we reusing the user class for that answer? Because it was duplication. We dried it up. Oh, it hamstrung us for a week to add that zero-point feature.
MIKE: [laughs]
DAVE: I'm done. I love you guys. Let's do another episode.
MIKE: Thank you to all our listeners. Hopefully, you've gotten some greater insight on how to use RSpec. Talk to you next time on the Acima Development Podcast.