Web App Testing & Tools

Rules to Avoid Untestable Code

Miško Hevery

Miško Hevery

Qwik Creator (Previously Angular)
Web App Testing & Tools

Check out a free preview of the full Web App Testing & Tools course

The "Rules to Avoid Untestable Code" Lesson is part of the full, Web App Testing & Tools course featured in this preview video. Here's what you'd learn in this lesson:

Miško walks through several coding issues that create untestable code. Some of these techniques include mixing instantiation and logic, global state usage, and using too many conditionals.


Transcript from the "Rules to Avoid Untestable Code" Lesson

>> So when you ask people you're an evil developer, what make code is hard to test? They'll say, ooh, make things private. I mean, I don't know, using final keyword would be cool into saying object.freeze in JavaScript. Have methods that are very complicated. And sure, to a lot of degrees, this makes the situation harder because you cannot monkey patch things, because the object is frozen.

Or you cannot get a hold of things because they're hidden somewhere in a special property. Or if the method is too long, then it becomes really complicated to write a test. But fundamentally, the reason why code is hard to test is because you can't separate it out. You can't isolate it.

And the reason you can't isolate it is because the code which is responsible for constructing your application and the code that represents the logic are intermixed, so you can't take it apart. And the reason why they're intermixed is that you'll have things that you'll have code that looks for other pieces of code, and you'll see a lot of snow as love Demeter or the train wreck.

You'll you have something called A, and you'll say something like A.B.C.D.F, and you kinda walk the tree to get a hold of something that you want. This can also be done as you call a function that returns another function, which you call another function on it to return whatever you want.

And you're just kinda working it instead of just being given the thing that you need at the beginning. Doing work in a constructor, so when we're doing the GitHub API test, we created a helper function that created a promise that allowed us to manipulate the promise from the outside, right?

It kind of made sure that the resolve and reject was exposed to the outside world. But notice, we didn't do anything else inside of that function. If we wrote this function where inside a way, we would automatically also put the logic for returning something or verifying. It would be really difficult to reuse or use it anywhere else, because we would be essentially doing work in the constructor, right?

The thing that was responsible for constructing it should do nothing else, just construct it. And so in our case, it just did that. And it was a natural consequence to us writing the test first, because why would you write it in a way? Because any other way would be more complicated to write.

And so if you write test first, so obviously, you're gonna write the simplest thing you think of. Nobody's gonna make the test intentionally complicated, that's just not how we are as humans, right? Okay, the other one that really should be worth talking about is the global state. And global state is super problematic, and it's more problematic on a server than it is on the client.

So for example, you write an application that takes a currently logged-in user and shoves it somewhere in the global state, so anywhere in the code base, you can just say this global variable and you get the username. It surely makes your life easier as a developer, but now you decide, I'm gonna pre-render the application on a server.

But on the server, you have multiple threads of execution happening, right? Because, I mean, it's not multithreaded, but multiple awaits are happening continuously. The Node.js gets multiple requests, and it's processing multiple requests concurrently. When request A is waiting on a database, the server can process request B. And now, the global variable becomes a problem, right?

Because if request A sets the user to be user A and request B now runs, it can't just go and set it to request B because there's only one copy of it. So that's an example of where it becomes problematic. But it also is problematic, because global state is hard to get to.

Externally, I don't know what kinda global state the application relies on, because when I write a unit test, I wanna be able to control all the dependencies, which include dependencies in the global space. And this global space might be difficult to get to, or hidden in some way, because typically, we don't wanna expose it to the outside world, which means we also are hiding it from the tests.

So global state is particularly problematic. And singletons are just another form of global state, where you have one global cache for your application. Great, you go to the server, not so great. But also, inside of a unit test, it's not so great because test A runs, it sets up some information inside your singleton cache.

Test B runs, and all of the sudden test B fails because the cache contains some values that you didn't expect. You focus the test by saying it.only, and now you run the test by itself. And now the cache isn't poisoned, and now your test passes, and now you're going crazy, like what kind of a test is this?

It's passing by itself, but it's failing as a group. And you can see how that would become really difficult really quick. Well, I'm not gonna cover the deep inheritance, because that's not really applicable to JavaScript. But too many conditionals is just the question of, Something known as cyclomatic complexity.

Have you heard the term? Cyclomatic complexity just says, if you look at your function, how many decision points are there? The IF statement is a decision point, right, because you could go one way or the other way. You could either go to the L's branch or then branch.

So cyclomatic complexity just adds up all the different paths that you can have through your function, right? A simple function has a cyclomatic complexity of one, because there's only one way it can execute. Whereas if you have an IF statement, at a minimum you have cyclomatic complexity of two, because you can execute through the IF branch or the Then branch.

And if you have two instead IF statements, now you have a cyclomatic complexity of four, because you can go through the first IF, but the second Then, and it adds up like that. Go ahead.
>> There's a question around understanding mixing new with logic, is that just when you're creating a new class that it's doing too many things, or?

>> Yeah, so let's say we had a piece of code that would load the dataset for clustering, and then run it through the clustering algorithm and then convert it into the desired output for the cluster component. Such a situation would be extremely difficult to test, because I would like to now create a clustering with my own dataset, but I can't because the code that is fetching the data and the code that is doing the clustering are all in the same function.

So now I have created a problem for myself, because I cannot split it apart. So if you notice the way we structured our code base, as we made sure that the act of passing the data into the clustering algorithm was always separate from the actual clustering algorithm, right, so that we could always call it with something else.

You could make it simpler on yourself by just saying like, the first argument, I'm not gonna ask you for. Instead, I'm just gonna call the underlying method which was load dataset. But now, you would mess yourself up for testing purposes, because you can no longer pass in a smaller, simpler test set.

So that's what I mean by mixing the logic, which is the clustering with the construction, which is how do you assemble the pieces together, right? So I can teach you about writing test and the answer is nothing, but I can only teach you stuff about writing testable code.

This was written back in the day before I mainly did Java, that's why I said, good object-oriented programming. But really, it comes down to just good practices that is decoupled, right? You wanna have code that is decoupled, think Lego blocks. You wanna be able to work with Lego blocks individually.

You need to have some way of getting hold of these dependencies. Dependency injection is one way that's popular in the Java world. But even if you're not using dependency junction per se, as long as you have a way of getting hold of the dependencies in a practical sense, you're really practicing this particular thing.

Test-driven development is useful, because it naturally forces you to write simple tests. Nobody is gonna write a complicated test. Everybody writes something simple first, right? And if you write tests first, then your code will naturally just do the right thing. Because inside of the test, you will write something very simple, like, imagine I have these four data points, now cluster them, I expect to see two subsets.

That's a pretty straightforward thing, right? And naturally, in the test, you're gonna force a way of passing the dataset into the clustering algorithm. And so the test will naturally force you to make sure that you can assemble the code in lots of different ways. So you can assemble it with dataset A versus dataset B, with diagonal points, with one point, with many points, and so on and so forth.

Learn Straight from the Experts Who Shape the Modern Web

  • In-depth Courses
  • Industry Leading Experts
  • Learning Paths
  • Live Interactive Workshops
Get Unlimited Access Now