DEV Community

Andrei Eres
Andrei Eres

Posted on

Better Testing with Dependency Injection

I prefer TDD for development. But in many cases, it's too hard to write tests when we use external services.

Problem

Let's imagine we have a model that we use for caching any hashes to a store, e. g. Redis. We will use python and pytest.

class Cache(Model):
    key: str
    data: dict
Enter fullscreen mode Exit fullscreen mode

We need to implement a simple repository to manage caching to the storage. It can look like so.

class CacheRepository:
    def save(self, cache):
        return storage.set(cache.key, cache.json())


def test_saves_cache_to_storage():
    cache = CacheFactory()
    repo = CacheRepository()

    repo.save(cache)

    assert storage.get(cache.key) == cache.json()
Enter fullscreen mode Exit fullscreen mode

So. It looks like we have an extra thing to remember in our test environment, say, a local computer or CI container. We need to initialize storage before tests and clean up and close it after. And of course, waste time to connect, no matter how fast it is.

We have lots of packages that can help us to mock Redis using pytest. But the easiest way is framework agnostic. It's the usage of Dependency Injection.

Solution with DI

What Dependency Injection is. The simplest explanation of DI is a case when we instead of using an initialized instance of dependency, pass it as argument.

Look, how we can implement the same code using DI. It seems like the previous version. But now we have more space for dealing with storage.

class CacheRepository:
    def __init__(self, storage=storage):
        self.storage = storage

    def save(self, cache):
        return self.storage.set(cache.key, cache.json())


class MockStorage:
    _storage = {}

    def set(self, key, value):
        self._storage[key] = value

    def get(self, key):
        return self._storage.get(key)


def test_saves_cache_to_storage():
    storage = MockStorage()
    cache = CacheFactory()
    repo = CacheRepository(storage=storage)

    repo.save(cache)

    assert storage.get(cache.key) == cache.json()
Enter fullscreen mode Exit fullscreen mode

Now we don't depend on storage in our unit tests. We won't waste time for connection, and won't think about how to set up and clean up storage around tests.

When we write integration tests we have to use real storage. But it is another story. Using DI we can check business logic with lots of fast unit tests and run only a few slow tests to see how the system works together.

Examples from other languages

With ruby and rspec. To get a link for a telegram file using a bot we have to provide a bot's token in a link. Instead of revealing our credentials in the testing suite, we can just provide it throw DI.

class TelegramFile
  def initialize(bot:, file_id:)
    @bot = bot
    @file_id = file_id
  end

  def link
    "https://api.telegram.org/file/bot#{bot_token}/#{file_path}"
  end

  private

  attr_reader :bot, :file_id

  def file_path
    file.dig("result", "file_path")
  end

  def file
    bot.get_file(file_id: file_id)
  end

  def bot_token
    bot.token
  end
end

describe TelegramFile do
  describe "#link" do
    it "returns file link" do
      bot = instance_double("Telegram::Bot::Client", token: "_123", get_file: file_response)
      file = described_class.new(bot: bot, file_id: "file_id")

      expect(file.link).to eq "https://api.telegram.org/file/bot_123/photos/file_5.jpg"
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

With typescript and jest. We implement a simple helper that says includes site link necessary utm marks. As we have no document.location.search in our test environment, we can just pass it using DI.

export class UtmMatcherService {
  constructor(
    private combinations: Record<string, string>[],
    private search: string = document.location.search,
  ) {}

  hasMatches = (): boolean => {
    return this.includesAtLeastOneOfCombination()
  }
}

describe('UtmMatcherService', () => {
  describe('#hasMatches', () => {
    const combinations: Record<string, string>[] = [
      {utm_source: 'stories', utm_medium: 'mobile'},
      {utm_source: 'facebook'},
    ]

    context('when we have one suitable utm', () => {
      const search = '?utm_source=facebook'

      it('returns true for one utm', () => {
        const matcher = new UtmMatcherService(combinations, search)

        expect(matcher.hasMatches).toBe(true)
      })
    })
  })
})
Enter fullscreen mode Exit fullscreen mode

TLDR

Dependency Injection can do our tests faster. It is a simple and framework agnostic method. Instead of setup and checking external services we can just use their mocks throw arguments.

Top comments (0)