-3

I am a JavaScript and Python developer. Here is a unit testing code snippet using jestjs testing framework:

index.ts:

import dotenv from 'dotenv';

export class OsEnvFetcher {
  constructor() {
    const output = dotenv.config();
    if (output.error) {
      console.log('Error loading .env file');
      process.exit(1);
    }
  }
}

index.test.ts:

import { OsEnvFetcher } from './';
import dotenv from 'dotenv';

describe('OsEnvFetcher', () => {
  afterEach(() => {
    jest.restoreAllMocks();
  });
  it('should pass', () => {
    const mOutput = { error: new Error('parsed failure') };
    jest.spyOn(dotenv, 'config').mockReturnValueOnce(mOutput);
    const errorLogSpy = jest.spyOn(console, 'log');
    const exitStub = jest.spyOn(process, 'exit').mockImplementation();
    new OsEnvFetcher();
    expect(dotenv.config).toBeCalledTimes(1);
    expect(errorLogSpy).toBeCalledWith('Error loading .env file');
    expect(exitStub).toBeCalledWith(1);
  });
});

The outcome for the unit testing:

 PASS  stackoverflow/todo/index.test.ts (11.08s)
  OsEnvFetcher
    ✓ should pass (32ms)

  console.log
    Error loading .env file

      at CustomConsole.<anonymous> (node_modules/jest-environment-enzyme/node_modules/jest-mock/build/index.js:866:25)

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |     100 |       50 |     100 |     100 |                   
 index.ts |     100 |       50 |     100 |     100 | 6                 
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        12.467s

The test method in the example is very common in a js project using Arrange, Act, Assert pattern. Due to the dotenv.config() method will do some file system I/O operations, it has a side-effect. So we will make a stub or mock for it. In this way, our unit tests have no side effects and are tested in an isolated environment.

The same applies to python. We can use unittest.mock mock object library to do the same thing. I'm very comfortable with these unit testing approaches.

Now, I switch to go, try to do the same thing. Code here:

osEnvFetcher.go

package util

import (
    "log"
    "os"
    "github.com/joho/godotenv"
)

var godotenvLoad = godotenv.Load

type EnvFetcher interface {
    Getenv(key string) string
}

type osEnvFetcher struct {}

func NewOsEnvFetcher() *osEnvFetcher {
    err := godotenvLoad()
    if err != nil {
        log.Fatal("Error loading .env file")
    }
    return &osEnvFetcher{}
}

func (f *osEnvFetcher) Getenv(key string) string {
    return os.Getenv(key)
}

osEnvFetcher_test.go:

package util

import (
    "testing"
    "fmt"
)

func TestOsEnvFetcher(t *testing.T) {
    old := godotenvLoad
    defer func() { godotenvLoad = old }()
    godotenvLoad = func() error {
        return 
    }
    osEnvFetcher := NewOsEnvFetcher()
    port := osEnvFetcher.Getenv("PORT")
    fmt.Println(port)
}

The test case is not finished. I'm not sure how should I mock, stub, or spy godotenv.Load method(Equivalent to dotenv.config()) and log.Fatal method? I found this mock package - mock. But godotenv package has no interface, it's composed by functions.

I'm looking for some ways like jest.mock(moduleName, factory, options) and jest.spyOn(object, methodName).Or, like stubs and spies of sinonjs. Or, like spyOn of jasmine. These methods can cover almost any test scenario. No matter use DI or import modules directly.

I saw some ways, but they all have their own problems.

E.g. https://stackoverflow.com/a/41661462/6463558.

What about I need to stub ten methods which have side-effect? I need to assign these methods of a package ​​to 10 variables and replace them with the mocked version in test cases ten times before running the test. It's non-scalable. Maybe I can create a __mocks__ directory and put all the mocked version object in it. So that I can use them in all test files.

Use Dependency Injection is better. In this way we can pass the mocked objects to the function/method. But some scenarios are directly importing the packages and use the methods of packages(3rd or built-in standard library). This is also the scenario in my question. I think this scenario is inevitable and will definitely appear on a certain layer of the code.

I can handle this scenario easily using jestjs, E.g.

util.js,

exports.resolveAddress = function(addr) {
  // ...
  const data = exports.parseJSON(json);
  return data;
}
exports.parseJSON = function(json) {}

main.js:

// import util module directly rather than using Dependency Injection
import util from './util';
function main() {
  return util.resolveAddress();
}

main.test.js:

import util from './util';
const mJson = 'mocked json data';
jest.spyOn(util, 'parseJSON').mockReturnValueOnce(mJson)
const actual = main()
expect(actual).toBe('mocked json data');

I directly imports util module and mock util.parseJSON method and its returned value. I am not sure if Go's package can do this. For now, these issues are Arrange issues.

Besides, I also need to check the methods are indeed called to make sure the code logics and branches are correct. (E.g. the .toBeCalledWith() method using jestjs). This is Assert issue.

Thanks in advance!

Jonathan Hall
  • 75,165
  • 16
  • 143
  • 189
Lin Du
  • 88,126
  • 95
  • 281
  • 483
  • 2
    You certainly can't mock/stub/etc. `log.Fatal`, that's *a* function, can't be tampered with, it's body can't be switched with some other function body. `godotenvLoad`, on the other hand, is just a variable, you can replace its value with anything you want as long as its type matches the variable's type. – mkopriva May 22 '20 at 11:06
  • In Go mocking is uncommon while fakes and stubs are the normal way to test stuff which has dependencies. This often results in much cleaner tests. – Volker May 22 '20 at 12:10

1 Answers1

0

Here is my solution based on this answer:

osEnvFetcher.go:

package util

import (
    "log"
    "os"
    "github.com/joho/godotenv"
)

var godotenvLoad = godotenv.Load
var logFatal = log.Fatal

type EnvFetcher interface {
    Getenv(key string) string
}

type osEnvFetcher struct {}

func NewOsEnvFetcher() *osEnvFetcher {
    err := godotenvLoad()
    if err != nil {
        logFatal("Error loading .env file")
    }
    return &osEnvFetcher{}
}

func (f *osEnvFetcher) Getenv(key string) string {
    return os.Getenv(key)
}

osEnvFetcher_test.go:

package util

import (
    "testing"
    "errors"
)


func mockRestore(oGodotenvLoad func(...string) error, oLogFatal func(v ...interface{})) {
    godotenvLoad = oGodotenvLoad
    logFatal = oLogFatal
}

func TestOsEnvFetcher(t *testing.T) {
    // Arrange
    oGodotenvLoad := godotenvLoad
    oLogFatal := logFatal
    defer mockRestore(oGodotenvLoad, oLogFatal)
    var godotenvLoadCalled = false
    godotenvLoad = func(...string) error {
        godotenvLoadCalled = true
        return errors.New("parsed failure")
    }
    var logFatalCalled = false
    var logFatalCalledWith interface{}
    logFatal = func(v ...interface{}) {
        logFatalCalled = true
        logFatalCalledWith = v[0]
    }
    // Act
    NewOsEnvFetcher()
    // Assert
    if !godotenvLoadCalled {
        t.Errorf("godotenv.Load should be called")
    }
    if !logFatalCalled {
        t.Errorf("log.Fatal should be called")
    }
    if logFatalCalledWith != "Error loading .env file" {
        t.Errorf("log.Fatal should be called with: %s", logFatalCalledWith)
    }
}

The outcome with coverage for the test:

☁  util [master] ⚡  go test -v -coverprofile cover.out
=== RUN   TestOsEnvFetcher
--- PASS: TestOsEnvFetcher (0.00s)
PASS
coverage: 80.0% of statements

coverage html reporter:

enter image description here

Lin Du
  • 88,126
  • 95
  • 281
  • 483