Recommanded Free YOUTUBE Lecture: <% selectedImage[1] %>

Contents

소개

견고하고 확장가능한 Go 애플리케이션 개발 후속이다.

지난 문서에서 Go 애플리케이션에 클린아키텍처를 적용했다. 하지만 구조만 잡았지 세부적인 것들은 생략하고 넘어갔다. 여기에서는 테스트를 비롯하여 해당 애플리케이션이 정말로 유연하고 확장가능한지를 사례를 기반으로 살펴볼 것이다.

먼저 지난 번에 다뤘던 아키텍처를 간단히 정리하겠다.

 레이어간 통신

애플리케이션은 여러 개의 레이어로 구성한다. 이때 의존성은 안쪽으로만 흐르도록 해야 한다. 즉 Controllers는 Use Case에 종속되지만 Use Cases가 Controllers에 종속되어서는 안된다. 이렇게 안쪽으로만 의존성이 흐르게 하면서, 각 레이어가 하위 레이어를 추상화하게 된다.

나는 애플리케이션 개발의 가장 중요한 요소를 아래와 같이 정의하고 있다.
  1. 구조 : 요즘엔 클린 아키텍트, 베스트 프랙티스 등의 형태로 좋은 구조들이 많이 나오고 있다. 이들 구조 중 하나를 잡아서 따라하는 것 만으로도 괜찮은 구조를 만들 수 있다. 구조가 허약하면 나머지 기술이 아무리 좋아도 잔기술이 될 수 밖에 없다.
  2. 테스트 : 애플리케이션에 가치를 더 해나가기를 원한다면 테스트는 필수적이다. 가치는 코드의 형태로 구현된다. 가치의 추가/변경은 코드의 복잡도를 높인다. 이 복잡도는 테스트로 관리 할 수 있다. 테스트가 부족한 코드는 관리가 안되는 코드다.
난 이 두가지만 지키면 된다고 본다. 여기에 스타일 가이드 등을 얹어주면 될 것이다.

테스트

이 애플리케이션은 Model, Repository, Use Case, Delivery 4개의 레이어로 구성되어있다. 각 레이어별로 테스트 환경을 만든다. 필요한 경우 mock을 개발한다.

Mock에 대해서

Mock는 단위테스트에 사용한다. 테스트 중인 객체는 다른 객체에 대한 종속성을 가질 수 있다. 우리가 테스트하는 애플리케이션은 레이어 구조를 가지는데, 각 레이어는 하위레이어에 종속된다. 이는 하위 레이어의 기능이 준비되어야 테스트가 가능함을 의미한다. 원리적으로는 하위 레이어 기능이 모두 구현된 상태에서 테스트를 하면 좋지만 종속성이 걸리게 되므로 테스트를 어렵게 한다. 특히 로컬에서 개발 할 때 더욱 그렇다.

Mock은 실제 객체의 동작을 시뮬레이션 하는 모의 객체로 다른 객체를 교체함으로써, 종속성을 최소화하면서 테스트를 할 수 있도록 돕는다.

Repository 테스트

Mock 테스트

저장소는 외부에 있는 데이터베이스, API 서버 등에 종속성이 걸린다. 테스트를 위한 두 가지 방법이 있다.
  1. 데이터베이스, API 서버등을 직접 구성해서 테스트 한다.
  2. Mocking 한다.
나는 1번 방식을 선호한다. 데이터베이스를 설치하거나 외부 API 서버를 구현하는 것은 힘든 작업이 될 수 있겠지만 직관적이고 명확한 장점이 있다. 그리고 요즘에는 도커를 이용해서 CICD에 쉽게 녹일 수 있다. API의 경우에도 Postman mock 서버를 이용하거나 프레임워크로 직접 만들면 되는 일이다.

데이터 베이스는 아래와 같이 테스트 한다.
  1. 애플리케이션과 종속성이 있는 테이터베이스 docker-compose로 만든다.
  2. 애플리케이션을 테스트 할 때, 컨테이너 환경에서 실제 데이터베이스를 띄워서 테스트 한다.
외부 API의 경우에는 아래와 같이 테스트 한다.
  1. Postman으로 mock 서버를 실행한다. 이 mock에 연결해서 테스트 한다.
  2. 혹은 mock api 서버를 구축해서 docker-compose로 만든다.
컨테이너를 이용해서 테스트 환경을 구성하는 것은 주로 docker를 다루는 이슈이므로 다루지 않겠다. 대신 mock를 이용해서 테스트 환경을 구성하겠다. 여러 mock 패키지 중 go-sqlmock을 선택했다.

package main

import (
	"database/sql"
)

func RecordState(db *sql.DB, userID, productID int64) error {
	tx, err := db.Begin()
	if err != nil {
		return err
	}

	defer func() {
		switch err {
		case nil:
			err = tx.Commit()
		default:
			tx.Rollback()
		}
	}()

	if _, err = tx.Exec("UPDATE products SET views = views + 1"); err != nil {
		return err
	}
	if _, err = tx.Exec("INSERT INTO product_viewers (user_id, product_id) VALUES (?, ?)", userID, productID); err != nil {
		return err
	}
	return nil
}
func main() {
	db, err := sql.Open("mysql", "root@/blog")
	if err != nil {
		panic(err)
	}
	defer db.Close()

	if err = RecordState(db, 1, 5); err != nil {
		panic(err)
	}
}

RecordState 함수를 테스트해야 한다. 아래와 같이 mock을 이용해서 테스트 했다.
package main

import (
	"fmt"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
)

// a successful case
func TestShouldUpdateStats(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
	}
	defer db.Close()

	mock.ExpectBegin()
	mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectExec("INSERT INTO product_viewers").WithArgs(2, 3).WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectCommit()

	// now we execute our method
	if err = RecordState(db, 2, 3); err != nil {
		t.Errorf("error was not expected while updating stats: %s", err)
	}

	// we make sure that all expectations were met
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Errorf("there were unfulfilled expectations: %s", err)
	}
}

// a failing test case
func TestShouldRollbackStatUpdatesOnFailure(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
	}
	defer db.Close()

	mock.ExpectBegin()
	mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectExec("INSERT INTO product_viewers").
		WithArgs(2, 3).
		WillReturnError(fmt.Errorf("some error"))
	mock.ExpectRollback()

	// now we execute our method
	if err = RecordState(db, 2, 3); err == nil {
		t.Errorf("was expecting an error, but there was none")
	}

	// we make sure that all expectations were met
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Errorf("there were unfulfilled expectations: %s", err)
	}
}
입력 값에 따라서 성공/실패 케이스를 만들어서, 테스트 할 수 있다.

Use Case 테스트

Delivery 테스트

참고