eBay Tech London

March 1, 2017 Shutl, Tech

TDD for your technical interview

By:

That technical challenge is coming, and when it does, do you want TDD by your side?

A true story

A year ago I had no experience with tests. During an interview, I was given the task of writing a method that returns the nth number of the Fibonacci series, without using recursion. I was so excited about this challenge, as I knew how the algorithm works and could confidently implement it without recursion. I even started thinking about performance optimisations! I was ready to get this over with; 3 people were looking at me and I was sure I was going to excel. Famous last words one might say!

I had to do this on paper (ouch!), so I started scribbling the loop. Then I was thinking about the number of variables that I would need, then their names, then the exact way they would be used / updated. Every time I changed my mind I wrote new code, erasing the existing one. After a minute or so, the paper I was handed looked bombarded with drawings and random snippets of code. I started to feel confused, trying to keep the logic in my mind and get the damn thing to work! Fortunately, I figured everything out after a few attempts, at which point I handed the paper and stated that everything was working correctly.

I was happy, thinking that I had successfully addressed the technical challenge, oblivious to the fact that I had come to the solution in a completely disorganised way. I had not thought of edge cases and my proof of correctness was limited to my word. Furthermore, there was nothing modular or extensible about the code, which in a real scenario could have added technical debt. Finally, it is now clear to me that I was lucky to solve the issue at all. Under this stress, something slightly more complex could have lead me to the point of utter confusion and failure.

At the end of the interview the lead developer took away the paper with my solution, and only at that point did I get the feeling that something was not ideal.

Life after TDD

After a while I joined Shutl. I found myself surrounded by some of the best developers I’ve met, and their way of writing code was so much more structured. It wasn’t about having the inspiration, the super concentration or the stars aligning, it was about following certain best practices that lead to writing reliable and maintainable code.

One of these practices is TDD, which is not something easy to do at all. It’s about doing things backwards, leaving the code of the application until the very end. At the beginning it’s slow, complex and mostly unintuitive. Learning TDD hurts, but the tradeoff is software that won’t make your days miserable down the road.

An interview revisited

Recently, I thought about that interview test, and wondered if it could have been accomplished easier with TDD. Clearly it could! I would have been almost guaranteed to converge to the solution, with much less confusion and stress. My code would be proven to work through tests and more scenarios would have been thought of! Progress would have been slower, but I think it’s better to show a partial but correct solution rather than a mess of spaghetti code (held together by hope and luck). OK, maybe I’m stretching my point a bit, so here’s a tweet by J. B. Rainsberger:

“Worried that TDD will slow down your programmers? Don’t. They probably need slowing down.”

The purpose of this post is to solve that interview test using TDD principles. This should produce better code, in a more straightforward way, right? Let’s try and see!

Problem specification

Produce a method that prints the nth occurrence of the Fibonacci series, given n as a parameter. This should not make recursive calls.
As a bonus, modify the code to keep printing Fibonacci numbers indefinitely. The parameters for this Fibonacci series are n0=0, n1=1

Initial thoughts

Testing the actual printing does not seem like the way to go. We need to test that the calculation works correctly and this has little to do with what is printed in the screen. Therefore, it feels like this challenge requires at least 2 methods:

  • One that returns the result of the actual calculation
  • Helper methods to display the results to the user((This violates the Single Responsibility principle, but we’re keep everything in one place as this is a quick interview coding exercise)).

This would make the testing easier and add flexibility on which output to use. When I solved this without TDD, that separation didn’t even occur to me.

Code structure

Based on my initial thoughts, an initial code structure will look like this:

class FibonacciTdd
    def print(term):
        # print the result
    end

    def calculate(term):
        # calculate the correct fibonacci number
    end
end

Tests

To create the list of tests, I followed these steps:

  1. I made a list of results that this method would produce, given certain inputs. This list included the inputs and results for all the cases I wanted to handle. This was a brainstorming exercise, thinking in the problem domain rather than about implementation details.
  2. I ordered the tests in order of ascending technical complexity. This meant that the top tests would be the ones that require the simplest code to pass, whereas the bottom ones would only pass when the problem has been fully solved.

The tests I came up with may be seen below:

  1. When given 0, expect 0
  2. When given 1, expect 1
  3. When given 2, expect 1
  4. When given 3, expect 2
  5. When given 4, expect 3
  6. When given 5, expect 5
  7. When given 10, expect 55
  8. When given -1, raise an ArgumentError (edge case: handle negative input)
  9. When given 10, print 55 (test the printing functionality)

When did I have enough tests? I (again) followed J. B. Rainsberger’s suggestion:

“Test until fear turns to boredom.”

Once we are happy that the code is sufficiently tested and new tests are not adding more safety, it’s time to stop adding tests. The goal is to have the minimum amount of tests in order to be safe; having a ton of tests will slow us down and will also add maintenance overhead every time we refactor our code.

Now it’s time to start coding the solution. TDD dictates the following steps:

  1. Add one more test to the test suite.
  2. Run the entire test suite, at least the latest test should fail. If not, make sure that the test is actually testing what you expect: Temporarily change the code to make the test fail, just to make sure that the correct area in the code is actually tested.
  3. Only write enough code to make the tests pass. Not the code you know should be written, just the minimum amount of code to pass the test. That might be some code to make the test compile or just a return statement.
  4. Once the test passes, consider refactoring the code. Starting from a passing test means that we can refactor as much as we please and always revert to a valid state if the refactoring doesn’t work out. It also means that once the tests are green, we know that the refactoring is working as expected.
  5. If more tests need to be added, go to step 1.

Enough Talking! Show me the code!((https://github.com/nennes/tdd_for_your_interview))

The code lives in this repo, and here’s how it looked like after I had made each test pass:

Test 1:
it 'should return 0 when called with 0' do
    expect(@fibonacci.calculate(0)).to eq(0)
end
New/updated code:
class FibonacciTdd
    def calculate(term)
        return 0
    end
end

Test 2:
it 'should return 1 when called with 1' do
    expect(@fibonacci.calculate(1)).to eq(1)
end
New/updated code:
def calculate(term)
    return term
end

Test 3:
it 'should return 1 when called with 2' do
    expect(@fibonacci.calculate(2)).to eq(1)
end
New/updated code:
def calculate(term)
    if term > 0
        return 1
    else
        return 0
    end
end

Test 4:
it 'should return 2 when called with 3' do
    expect(@fibonacci.calculate(3)).to eq(2)
end
New/updated code:
def calculate(term)
    if term < 2
         return term
    else
        return term-1
    end
end

Test 5:
it 'should return 3 when called with 4' do
    expect(@fibonacci.calculate(4)).to eq(3)
end
New/updated code:

No code needed to be changed. I still modified it temporarily to prove that the test would fail when the code was not working correctly, and then restored it to it’s previous (passing) state.


Test 6:
it 'should return 5 when called with 5' do
    expect(@fibonacci.calculate(5)).to eq(5)
end
New/updated code:
def calculate(term)
    if term < 2 || term == 5
        return term
    else
        return term-1
    end
end

At this point, I had code that was passing the tests but was reaching the limits of if statements. It was a good time to refactor, taking into account the requirements of this exercise.
This required the bulk of the mental energy for this problem, TDD couldn’t magically fix the problem for me. It had however offered some benefits:

  • I got to play around with simpler versions of this problem
  • I had a full suite of tests available, giving me feedback while trying different solutions.
  • Even if I were to completely fail on the task, I would still have something to show for.
Refactored code:
def calculate(term)

    if term < 2
        return term
    end

    term_minus_1 = 1
    term_minus_2 = 0
    term_current = nil

    2.upto(term) do
        term_current = term_minus_1 + term_minus_2
        term_minus_2 = term_minus_1
        term_minus_1 = term_current
    end

    return term_current
end

Test 7:
it 'should return 55 when called with 10' do
    expect(@fibonacci.calculate(10)).to eq(55)
end
New/updated code:

No changes were required. The code was able to handle more complex scenarios, and this gave me confidence that it was working correctly. Fear was slowly turning into boredom, so I chose to not write any more tests for the core functionality.


Test 8:
it 'should raise an exception when called with -1' do
 expect{ @fibonacci.calculate(-1) }.to raise_error(ArgumentError)
end
New/updated code:
def calculate(term)
    
    if term < 0
        raise ArgumentError
    elsif term < 2
        return term
    end

    term_minus_1 = 1
    term_minus_2 = 0
    term_current = nil

    2.upto(term) do
        term_current = term_minus_1 + term_minus_2
        term_minus_2 = term_minus_1
        term_minus_1 = term_current
    end

    return term_current
end

Test 9:
it 'should print 55 when called with 10' do
    expect{ @fibonacci.print(10) }.to output("55\n").to_stdout
end
New/updated code:
def print(term)
    puts calculate(term)
end

Solving the bonus task of printing infinite Fibonacci numbers was achieved through adding another function:

def print_forever(start_fibonacci_term = 0)
    loop do
        print(start_fibonacci_term)
        start_fibonacci_term = start_fibonacci_term + 1
    end
end

Testing this would have been a bit more challenging because it requires messing with how the loop works. I felt this was not necessary, as this method was so simple that there were obviously no bugs (as opposed to something that is so complex that there are no obvious bugs((https://en.wikiquote.org/wiki/C._A._R._Hoare))). Testing should be focused in the areas with logic, rather than trying to cover every line.

Lessons learned

I found the experience of going through this exercise a year later eye-opening. Some outcomes were expected, others were not.

Expected outcomes

  • Better structured code
  • Handling of edge cases
  • Confidence in the correctness of the code
  • Easier to reach the solution

Not really expected outcomes

  • Significantly slower
  • Didn’t make the difficulty disappear, still puzzled me
  • Not practical on paper, required the instant test feedback

So, is it worth doing TDD in an interview?

In general, TDD is worth spending the time to learn, as it will help you a lot down the road. Doing TDD on bite-sized (programming kata) problems is manageable, since they pose way less challenges than big interconnected projects. Like waaaay less.
In order for TDD to make sense in an interview, you need to have practiced on small projects for a while. That will allow you to be comfortable with the basics and also be fast enough to get some work done within the time allocated. Also, different companies place different value on TDD, depending on their processes. Some think it’s vital, whereas others find it to be an optional, time-expensive and mostly irrelevant skill.


Therefore, purely from an interview perspective, it’s not a silver bullet. It makes most sense when the company is already doing TDD and you have spent time getting good at it. In this scenario, TDD would actually reduce the risk of failing to solve the task and also make you look really good!


Personally, after a year at Shutl, I’d never go back to writing and maintaining untested code. Yes, TDD is painful. On the other hand, a system without tests is like a caged wild animal; unpredictable and dangerous if you get too close.

Agree? Disagree? Write a comment below!

2 Comments

  • Nabil says:

    Thanks Yiannis!
    This makes sense and I found it really useful. Personally I tended to focus on writing a good working code but I see the point here.
    All the best

  • Lee Barker says:

    Steps 1-5 are just wasting time. Like you said yourself about Step 6 (where you actually solved the problem all at once)’
    > This required the bulk of the mental energy for this problem, TDD couldn’t magically fix the problem for me.

    The TDD ‘gains’ you claim:
    > I got to play around with simpler versions of this problem
    This didn’t really help you, as the solution was so basic, and your solutions to steps 1-5 gave you no insight into the solution for step 6.

    >I had a full suite of tests available, giving me feedback while trying different solutions.
    It’s not hard to write 5-6 simple test cases up front so TDD isnt saving you time here. In fact its often quicker to spend the time to specify multiple test cases up front rather than have to refactor away all the duplication later.

    >Even if I were to completely fail on the task, I would still have something to show for.
    I don’t think this is relevant. Step 5 code is still embarrassingly simple. I’m not sure it’s any better than having no code.

    I don’t mean to discredit TDD. I used to be a TDD fanatic too. But realise it’s not the end goal. You take the best practices and come out the other side. Take TDD to the next level. There’s no need for these baby steps. Yes, set up the testing framework, a few tests to describe the outcome you want, a class with a function you want to implement. Then _just implement the function_. There’s no need to drag it out with a zillion baby step test cases and pretend you are making progress by hacking together something basic. Get enough of a system in place to allow you to focus on the hard problem and solve it.

    My 2c (Been through TDD and back out the other side)

Leave a Reply

Your e-mail address will not be published. Required fields are marked *