Solving a Complex Code Problem? Break It Down With Tests!

Elevator.png
 

Gabriel Seidner is a full-stack software Engineer at NDCRx Software where he built STASH––a platform for helping pharmacies better manage their inventory. Gabriel's favorite part of coding is identifying design patterns that make code readable and easily changeable. In his free time Gabriel enjoys playing guitar and making dinner.

Gabriel+Profile.jpg
 

___________________________________

In my two years as a developer, it's become apparent to me that one of the most valuable skills one can have in the trade is the ability to break down big problems into a set of smaller ones. ‘Smart’ developers, from what I have seen, are not smart because they have super computer brains that can brute force through complex problems or clairvoyant magicians that can see things no one else can. Their ‘smarts’ lie in the ability to break down problems into pieces that anyone could solve ‘drunk at 3 am in the morning’. Because this skill is so central to the work of a developer, interviewers want to see how well a candidate can do this. So, I thought it would be valuable to take the kind of problem you might see in an interview, and go through the process of solving it together.

We’ll assume we have the following challenge:

Write a program that simulates how an elevator work.

This is a great problem to practice on because it is very broad and not well defined. As developers building large applications, we're often faced with large problems. Part of our job is to feel out the scope of those problems, give them definition and break them down into solvable challenges

Breaking Down the Process

Before we dive into this specific problem problem, let’s talk about how we break down problems

  1. Write out our user stories - What are the specific ways in which the user of our code will be able to interact with our code

  2. Write out a blueprint for our proposed classes and their API’s - What are the classes that we will need to implement to enable the desired behavior defined by our user stories

  3. Write some tests to test our classes -  Test driven development :)

  4. Code our classes to pass the tests

  5. Refactor -  Coding is always an iterative process

Breaking Down the Specifications

In order to begin coding we have to know what to code. Writing out user stories helps us define the smaller set of solvable problems. We begin by listing the things that one might want an elevator to do:

  • As a user outside the elevator, I want to be able to call the elevator

  • As a user inside the elevator, I want to be able to indicate what floor I am going to

  • As a user inside the elevator, I want the elevator to take me to my selected floor

  • As a user I’d like to know the floor the elevator is currently on

  • As a user outside the elevator, I want the elevator to only pick me up if, after picking me up, it will move in the direction that gets me closer to my floor.

Designing Our Classes

Now that we have a sense of what our elevator needs to do, we are ready to do some blueprinting about the classes we’ll need to implement. At this point, we don’t have a clear sense of the full architecture of this program, that is ok. Based on our user stories we can begin to make some educated guesses.

We’ll start with an Elevator class, and we’ll describe some of the things it needs to do, based on the user stories.

 
 

Testing

Then right away we’ll start with tests. Tests are a great way to start because not only are they a way to ensure our app does what it is supposed to do, but also they help us translate our user stories into code by breaking down the functionality and ensuring we write testable code. We’ll make some decisions about methods we’ll use to implement the desired behavior

 
 

This gives us the first bit of our framework. We then continue by filling out the testing criteria for each of these methods.

The first method:

 
 

We run the test and, as expected they fail because the methods don’t exist!. We proceed to write the methods.

 
 

In this way, we proceed with all of the other methods.  First the tests:

 
 

Then the methods to make them pass.

 
 

Addressing Complex Concerns

There are two guidelines to follow when it comes to dealing with complex code as your project grows:

  1. Your code will always change! Write code based on what you know and use your tests to help you adapt to new challenges.

  2. Start with the easy stuff first and address the more complex challenges later.

We can see both of these guidelines in practice here.

You’ll notice that we are missing the #call method implementation. When we wrote the tests, we realized that the specifications for the #call method were exactly the same as the specifications for the #request_stop method. Therefore, we realize that whether a person requests a stop from inside or from outside, the behavior is the same, and as such we only need one method. So, we changed our original plan and only defined a #call method.


Next up, you’ll see that we are still missing one method: #visit_next_floor.  I left this method for last because it felt the most complex and daunting. Having the rest of the methods implemented now frees our mind from other variables in the problem to tackle this more complex method.  We’ll tackle this problem in an iterative fashion.

Iterating on our Code

Now that we’re ready to tackle one of the more complicated pieces of our puzzle, we’ll go back through the process of:

  • Defining the basic behavior

  • Writing some tests

  • Adding more detail and complexity to the desired behavior

  • Writing some tests as we change our code to meet the new desired behavior

We begin, again, by making some educated guesses about what this method needs to do. It needs to:

  • Go to the next stop requested when there are stop requests in the queue

  • Not go anywhere if there are no requests in the queue

  • Remove the most recently visited floor from the stop request queue

Let’s write some tests that support this basic behavior:

 
 

Now that we have specs for the basics, let’s rethink the desired behavior. If we take a deeper dive into the requirements of our original user stories, we’ll see that there is a specific order we should follow in visiting floors:

  • As a user outside the elevator, I want the elevator to only pick me up if, after picking me up, it will move in the direction that gets me closer to my floor.

Admittedly, this user story is poorly defined and a bit fuzzy at this point. Now is the time to define it a bit more rigorously.  When we think about how elevators work, we recognize that

  1. Given a set of unsorted floors, they don’t visit floors in an unsorted order. For example, given the set of floors: [1, 5, 3, 6, 2], they don’t go from floor 1 to floor 5 to floor 3 back up to floor 6. That would be very inefficient. Instead, they visit floors in sorted order. So for the above example, an elevator would visit 1, then 2, then 5, then 6.


Furthermore, our experience riding elevators tells us that when we call an elevator, it will only stop if it is moving in the direction we intend to go. Otherwise it will come back and get us. So, its starting to feel like we do need a `#call` method that behaves differently from `#request_stop` after all! Once again, our code has changed as we’ve uncovered new requirements and better understood original requirements.

With these new insights and specifications, we are ready to change our code! Let’s modify our tests to support our new requirements!

We modify the #request_stop method’s test to keep the request_queue in order when adding new stop requests.

 
 
 
 

Just like we did for the the #request_stop and other methods above. We’d proceed with the remaining methods, following the iterative approach of

  1. Defining requirements

  2. Writing tests to reflect those requirements,

  3. Writing code to make the tests pass.

For example, for the #call method- which would enable users outside the elevator to make requests, the requirements could be something like this:

    1. It must keep track of the direction of the call

    2. It must keep track of the floor number from which the elevator is called

    3. It must keep the requests sorted

We’d proceed to write tests and then make them pass by fleshing out the methods. As we proceed in this way, we’d discover new requirements and we’d tackle those using the same cycle. New requirements might lead us to rethink some of the existing code, and we’d do so confidently by updating the tests and modifying the code to make them pass.

The goal of this blogpost is to highlight how the iterative process of defining requirements, writing tests and writing code to make them pass enables us to tackle big, complex problems. The full source-code, which takes the problem further than this post can be found here.