Breaking the File Dependency

tacoda

Ian Johnson

Posted on January 11, 2024

Breaking the File Dependency

In the last post, we ran into a problem with the file dependency. A little foresight also indicated that this will become a bigger problem as we go.

What we really need is a way to fake the file. Right now the details of this implementation are internal to the Task class. So, in order to test this effectively, we are going to have to change some things. Let's get started!

todo/task.py

class Task:
    def add(s):
        f = open('todo.txt', 'a')
        f.write(s)
        f.write("\n")
        f.close()
        s = '"'+s+'"'
        print(f"Added todo: {s}")
Enter fullscreen mode Exit fullscreen mode

This portion of the Task is the add method that we are interested in testing. Notice on the first line in the method, we are direction opening the file. This file, it follows, is a dependency of this method. That is, add depends on the todo.txt file. The problem is that we do not have an enabling point to use to inject custom behavior (which is what we want for the test).

Pulling Behavior Up

Now, we could pass in the filename to the add method. That would give us an enabling point, but it still suffers from the problem that it's looking at a file on disk. It also affects our API in a suboptimal way.

In this context, by API I mean the application programming interface. Namely, the way the method is called. Adding the filename would change our API from:
Task.add('Buy Milk') to: Task.add('Buy Milk', 'todo.txt').

As a matter of design, it also associates a unexpected parameter with a basic function of a task. How can we accomplish this in a better way? We could use the constructor:

class Task:
    def __init__(self, filename: str):
        self.f = open(filename, 'a')

    def add(self, s):
        self.f.write(s)
        self.f.write("\n")
        self.f.close()
        s = '"'+s+'"'
        print(f"Added todo: {s}")
Enter fullscreen mode Exit fullscreen mode

In this method, we pass the filename into the constructor. This gives us our enabling point: I can construct a Task with a different filename. This still isn't ideal, but that's the process that we are working through right now. We are pulling behavior up step-by-step.

Breaking the Dependency

When I refer to pulling behavior up, I mean up in the call stack. To be concrete, the file behavior now happens before we call add, when we construct the Task. Unfortunately, we are still tied to the filesystem. How do we fix this? By changing our input:

todo/task.py

class Task:
    def __init__(self, file):
        self.file = file

    def add(self, s):
        self.file.write(s)
        self.file.write("\n")
        s = '"'+s+'"'
        print(f"Added todo: {s}")
Enter fullscreen mode Exit fullscreen mode

Here is how our client code would be affected:

todo.py

with open('todo.txt', 'a') as f:
    task = Task(f)
    task.add(''.join(args[2:]))
Enter fullscreen mode Exit fullscreen mode

More progress! We have pulled the dependency out of the Task class (in this scenario). What is the advantage? Well, now we can inject our own file when testing. We could use a test file. But, that file dependency re-introduces the problem -- that tests could affect each other by changing the file on disk. So what's the solution? Fake the file using a Python object.

Stubbing Out a Fake File

For testing purposes, we'll need a fake file. To do that, we will have to create our own class that responds to the same interface as the file. Actually, right now we only care about writing, because that is the only method being invoked on the file in the method.

class FakeFile:
    pass
Enter fullscreen mode Exit fullscreen mode

Starting from a blank implementation, as we have before. Now, let's fake the write interface.

class FakeFile:
    def write(self, message: str):
        pass
Enter fullscreen mode Exit fullscreen mode

Now we can use this stub in our tests! Let's add our test for Task.add:

tests/test_task.py

def test_adding_task(capsys):
    fake_file = FakeFile()
    task = Task(fake_file)
    task.add("Buy Bread")
    captured = capsys.readouterr()
    assert "Buy Bread" in captured.out
Enter fullscreen mode Exit fullscreen mode
poetry run pytest
# tests/test_task.py ..
# 2 passed in 0.01s
Enter fullscreen mode Exit fullscreen mode

And it passes! So we have:

  • Broken the file dependency
  • Faked the file to test our add method
  • Passed in the file into the constructor in the client code

This may seem a little suspect. So, if you're like me, you'll want to see this fail too. Let's make that happen. Let's break it in the simplest possible way. We'll just update the expectation.

def test_adding_task(capsys):
    fake_file = FakeFile()
    task = Task(fake_file)
    task.add("Buy Bread")
    captured = capsys.readouterr()
    assert "Sell Bread" in captured.out
Enter fullscreen mode Exit fullscreen mode
poetry run pytest
# FAILED tests/test_task.py::test_adding_task - assert 'Sell Bread' in 'Added todo: "Buy Bread"\n'
Enter fullscreen mode Exit fullscreen mode

And we get a failure. Now we can feel more confident that this test gives us meaningful feedback. Swap the expectation back and run tests again to verify we are back to Green.

def test_adding_task(capsys):
    fake_file = FakeFile()
    task = Task(fake_file)
    task.add("Buy Bread")
    captured = capsys.readouterr()
    assert "Buy Bread" in captured.out
Enter fullscreen mode Exit fullscreen mode
poetry run pytest
# tests/test_task.py ..
# 2 passed in 0.01s
Enter fullscreen mode Exit fullscreen mode

Okay, here's the best part: running this test does not change the file on disk, because it's just using the fake object. This makes our tests:

  • Focused on our domain
  • Less brittle
  • Independent of the file system

Next time, we will update the Task.ls test to use the fake file and then write the remainder of the tests needed for Task. You may have noticed a lot of this functionality doesn't really belong in Task -- you're right, it should be in TaskList. We'll create those objects and tests iteratively as we continue.

Key Takeaways

  • Break dependencies by pulling them up into the constructor
  • Pass in those dependencies in client code
  • Create fake dependencies for your tests
πŸ’– πŸ’ͺ πŸ™… 🚩
tacoda
Ian Johnson

Posted on January 11, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Giving Our Tests Context
python Giving Our Tests Context

January 18, 2024

Refactoring Our Tests
python Refactoring Our Tests

January 17, 2024

Testing Our Tasks
python Testing Our Tasks

January 10, 2024

Mocking the File
python Mocking the File

January 15, 2024