Feb 13 2020

Isolated Testing in OOP

We’ve all seen it – heavy reliance database fixtures in unit tests. It’s not uncommon to find a unit test consisting of five or more fixtures as setting up for your “unit” test. While it is a good idea to have database transactions in some of your tests, surely they should not be in all of them. And surely not five or more fixtures in a single test. After writing one of these monstrosities, you may walk away with the feeling that this doesn’t seem very isolated. I know I do. After all, unit tests ought to be isolated right?

I personally struggle with this from time to time. There is never one clear silver bullet for reducing these number of fixture set-ups in tests.

One tip that I would like to share involves mutating objects to get around our nasty fixture issue. This may also work in functional programming through reassignments.

Quick side note: I am not a huge fan of object oriented paradigms – I prefer functional paradigms. Function composition and purity is much more appealing to me (and also easier to test). This is a post for another day though.

Consider the following scenario:

uuid = 'uuid_generate_v1()'
class Site(Base):
  __tablename__ = 'sites'
  id = Column(Text, primary_key=True, server_default=uuid)
  title = Column(Text)
  latest_post_id = Column(Text, ForeignKey('posts.id'))
  latest_post = sqlalchemy.orm.relationship("Post")

class Category(Base):
  __tablename__ = 'categories'
  id = Column(Text, primary_key=True, server_default=uuid)
  name = Column(Text)
  site_id = Column(Text, ForeignKey('sites.id'))
  site = sqlalchemy.orm.relationship("Site")

class Posts(Base):
  __tablename__ = 'posts'
  id = Column(Text, primary_key=True, server_default=uuid)
  name = Column(Text)
  category_id = Column(Text, ForeignKey('categories.id'))
  category = sqlalchemy.orm.relationship("Category")

So maybe this is a SaaS blog product that has a database of websites. Each website has a set of categories. Each category has a set of posts.

To get the latest post for a website, we would ideally join through category to posts to get the latest post. Having latest_post on the website schema breaks normalization. This is not great, but please just bear with me – the example is a bit contrived for the sake of brevity.

In any case, upon publishing a post we might want to write a test like:

class TestPublish()
  @pytest.mark.usefixtures("session")
  def testUpdatesLatestPost(session):
    w = fixtures.create_site(session)
    c = fixtures.create_category(session, site_id=w.id)
    p1 = fixtures.create_post(session, category_id=c.id)
    p2 = fixtures.create_post(session, category_id=c.id)
    w.latest_post_id = p1.id
    w.update(session)

    # update w.latest_post_id to current post
    p2.publish(session)
    fetched = session.query(Website).get(w.id)
    assert fetched.latest_post_id == p2.id

There’s a lot going on here, and the fixture code is taking up more room than the actual execution and assertion.

In the most ideal case, we ought to have: one line of setup code, the method execution, and one assertion. It doesn’t get more isolated than that.

First of all, it seems like the database is standing in our way here. Since OOP encourages object mutation, we should just be able to do attribute operations and ignore the database entirely. This will also encourage us to decouple the publish method from the database (notice that we are passing the session into there).

Of course, refactoring our test will require us to also refactor the implementation. This is a good thing! By isolating the tests, we are able to decouple the actual implementation. As for what the implementation would look like, I will leave that as an exercise for you.

class TestPublish()
  def testUpdatesLatestPost(session):
    w = Site(latest_post_id='some_old_post')
    Post(id='some_new_post').publish()
    assert w.latest_post_id == 'some_new_post'

Boom, isolated. It’s worth noting that the caller of “publish” in the implementation will now be required to call post.update() since session is no longer passed in. So while this is decoupled, there is the possibility that mutation can bite us by forgetting to update the post in the database. To ensure this does not become an issue, we would write another isolated test on the caller!