Golang testing outside gotour

Published on October 01, 2018

Golang testing outside gotour



    No one likes writing tests. Of course, I'm joking, everyone loves to write them! As prompted by tmlidy and HR, the right answer for interviews - I really love and write tests. But suddenly you like to write tests in another language. How to start writing covered code on go?

    Part 1. We test handler


    In go out of the box there is support for the http server in "net / http", so you can raise it without any effort. The opportunities that have opened up make us feel extremely powerful, and therefore our code will return the 42nd user.

    func userHandler(w http.ResponseWriter, r *http.Request) {
       var user User
       userId, err := strconv.Atoi(r.URL.Query().Get("id"))
       if err != nil {
          w.Write([]byte( "Error"))
          return
       }
       if userId == 42 {
          user = User{userId, "Jack", 2}
       }
       jsonData, _ := json.Marshal(user)
       w.Write(jsonData)
    }
    type User struct {
       Id     int
       Name   string
       Rating uint
    }

    This code receives the user id parameter as input, further emulates the presence of the user in the database, and returns. Now we have to test it ...

    There is a wonderful thing “net / http / httptest”, it allows you to simulate a call to our handler and then compare the answer.

    r := httptest.NewRequest("GET", "http://127.0.0.1:80/user?id=42", nil)
    w := httptest.NewRecorder()
    userHandler(w, r)
    user := User{}
    json.Unmarshal(w.Body.Bytes(), &user)
    if user.Id != 42 {
       t.Errorf("Invalid user id %d expected %d", user.Id, 42)
    }

    Part 2. Honey, we have an external API here.


    And why do we need to take a breath if we only have warmed up? Inside our services, sooner or later external api will appear. This is a strange often hiding beast that can behave as you please. For tests, we would like a more compliant colleague. And our newly known httptest will help us here too. As an example, the code to call an external api with data transfer is further.

    func ApiCaller(user *User, url string) error {
       resp, err := http.Get(url)
       if err != nil {
          return err
       }
       defer resp.Body.Close()
       return updateUser(user, resp.Body)
    }

    To defeat this, we can do an external API mock, the simplest version looks like this:

     ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
          w.Header().Set("Content-Type", "application/json; charset=utf-8")
          w.Header().Set("Access-Control-Allow-Origin", "*")
          fmt.Fprintln(w, `{
      "result": "ok",
      "data": {
        "user_id": 1,
        "rating": 42
      }
    }`)
       }))
       defer ts.Close()
       user := User{id: 1}
       err := ApiCaller(&user, ts.URL)

    ts.URL will contain a string of the format `http: //127.0.0.1: 49799`, which will be api mock that calls our implementation

    Part 3. Let's work with the base


    There is a simple way: to raise a docker with a base, roll up migrations, fixtures and start our excellent service. But we will try to write tests, having a minimum of dependencies with external services.

    Implementing work with the database in go allows you to replace the driver itself, and, bypassing 100 pages of code and thinking, I suggest you take the library github.com/DATA-DOG/go-sqlmock
    You can understand the sql.Db on the dock. Take a slightly more interesting example in which will be orm for - gorm .

    func DbListener(db *gorm.DB) {
       user := User{}
       transaction := db.Begin()
       transaction.First(&user, 1)
       transaction.Model(&user).Update("counter", user.Counter+1)
       transaction.Commit()
    }

    I hope this example at least made you think about how to test it. In “mock.ExpectExec” you can substitute a regular expression that covers the case you need. The only thing you need to remember is that the order of setting the expectations should coincide with the order and number of calls.

    func TestDbListener(t *testing.T) {
       db, mock, _ := sqlmock.New()
       defer db.Close()
       mock.ExpectBegin()
       result := []string{"id", "name", "counter"}
       mock.ExpectQuery("SELECT \\* FROM `Users`").WillReturnRows(sqlmock.NewRows(result).AddRow(1, "Jack", 2))
       mock.ExpectExec("UPDATE `Users`").WithArgs(3, 1).WillReturnResult(sqlmock.NewResult(1, 1))
       mock.ExpectCommit()
       gormDB, _ := gorm.Open("mysql", db)
       DbListener(gormDB.LogMode(true))
       if err := mock.ExpectationsWereMet(); err != nil {
          t.Errorf("there were unfulfilled expectations: %s", err)
       }
    }

    I found many examples on base testing here .

    Part 4. Working with the file system


    We tried our strength in different areas and accepted that everything was a good wet. It's all not so simple. I suggest two approaches, mock or use the file system.

    Option 1 - all mokay on github.com/spf13/afero

    Pros :
    • Nothing needs to be redone if you already use this library. (but then it’s boring to read it)
    • Work with a virtual file system, which will greatly speed up your tests.


    Cons :
    • Requires revision of existing code.
    • Chmod does not work in the virtual file system . But it can be a feature because the documentation states - “Avoid security issues and permissions”.

    Of these few points, I immediately made 2 tests. In the file system version, I created an unreadable file and checked how the system would work.

    func FileRead(path string) error {
       path = strings.TrimRight(path, "/") + "/" // независим от заверщающего слеша
       files, err := ioutil.ReadDir(path)
       if err != nil {
          return fmt.Errorf("cannot read from file, %v", err)
       }
       for _, f := range files {
          deleteFileName := path + f.Name()
          _, err := ioutil.ReadFile(deleteFileName)
          if err != nil {
             return err
          }
          err = os.Remove(deleteFileName) // после вывода удаляем файл
       }
       return nil
    }

    Using afero.Fs requires minimal improvements, but fundamentally changes nothing in the code

    func FileReadAlt(path string, fs afero.Fs) error {
       path = strings.TrimRight(path, "/") + "/" // независим от заверщающего слеша
       files, err := afero.ReadDir(fs, path)
       if err != nil {
          return fmt.Errorf("cannot read from file, %v", err)
       }
       for _, f := range files {
          deleteFileName := path + f.Name()
          _, err := afero.ReadFile(fs, deleteFileName)
          if err != nil {
             return err
          }
          err = fs.Remove(deleteFileName) // после вывода удаляем файл
       }
       return nil
    }

    But our fun will be incomplete if we do not find out how much faster afero than native.
    Minute benchmarks:

    BenchmarkIoutil       5000     242504 ns/op     7548 B/op     27 allocs/op
    BenchmarkAferoOs      300000     4259 ns/op     2144 B/op     30 allocs/op
    BenchmarkAferoMem     300000     4169 ns/op     2144 B/op     30 allocs/op

    So, the library is an order of magnitude ahead of the standard one, but now you can use the virtual file system or the real one.

    Recommend:

    haisum.github.io/2017/09/11/golang-ioutil-readall
    matthias-endler.de/2018/go-io-testing

    Afterword


    I honestly really like the 100% coverage, but the nonlibrary code does not need it. And even it does not guarantee protection against errors. Focus on the requirements of the business, and not on the ability of the function to return 10 different errors.

    For those who like to poke the code and start tests, the repository .