Go

Go로 작성된 로컬 파일 처리 CLI 도구 테스트 방법 3가지

드리프트2 2024. 9. 20. 17:24

Go로 작성된 로컬 파일 처리 CLI 도구 테스트 방법 3가지

Go 언어의 크로스 플랫폼 지원을 활용하여 로컬 파일을 처리하는 CLI 도구를 개발할 때, 효과적인 테스트 전략은 개발 생산성과 코드 품질에 직결됩니다.

 

파일 처리 로직이 복잡해질수록 테스트 코드 작성은 더욱 중요해지며, 적절한 테스트 패턴을 선택하는 것이 필수적입니다.

 

본 글에서는 Go로 작성된 CLI 도구의 파일 처리 로직을 테스트하기 위한 세 가지 패턴을 제시하고, 각 패턴의 장단점과 적용 시나리오를 자세히 설명합니다.

 

테스트 대상 코드 예시

다음은 본 글에서 설명할 테스트 패턴을 적용할 예시 코드입니다.

 

이 코드는 입력 디렉토리(input)의 모든 텍스트 파일에 한 줄을 추가하고, 결과를 출력 디렉토리(output)에 별칭으로 저장하는 기능을 수행합니다.

 

디렉토리 구조:

.
├── files
│   ├── input
│   │   ├── testFile0.txt
│   │   ├── testFile1.txt
│   │   └── testFile2.txt
│   └── output
└── src
    ├── main_test.go
    └── main.go

 

 

main.go:

package main

import (
        "io"
        "io/ioutil"
        "log"
        "os"
        "path/filepath"
        "strconv"
)

func main() {
        inputDir, err := filepath.Abs("../files/input/")
        if err != nil {
                log.Fatalln(err)
        }
        outputDir, err := filepath.Abs("../files/output/")
        if err != nil {
                log.Fatalln(err)
        }
        os.MkdirAll(outputDir, 0777) // os.Mkdir 대신 os.MkdirAll 사용하여 중첩 디렉토리 생성 가능

        insertAll(inputDir, outputDir)
}

func insertAll(inputDir, outputDir string) {
        files := dirwalk(inputDir)
        for i, f := range files {
                outputFile := filepath.Join(outputDir, "testFile"+strconv.Itoa(i)+".txt")
                r, err := os.Open(f)
                if err != nil {
                        log.Fatalln(err)
                }
                defer r.Close()
                w, err := os.Create(outputFile)
                if err != nil {
                        log.Fatalln(err)
                }
                defer w.Close()
                insert(r, w)
        }
}

func insert(r io.Reader, w io.Writer) {
        _, err := io.Copy(w, r)
        if err != nil {
                log.Fatalln(err)
        }
        w.Write([]byte("\nbar"))
}

func dirwalk(dir string) []string {
        files, err := ioutil.ReadDir(dir)
        if err != nil {
                log.Fatalln(err)
        }
        var paths []string
        for _, file := range files {
                if file.IsDir() {
                        paths = append(paths, dirwalk(filepath.Join(dir, file.Name()))...)
                        continue
                }
                paths = append(paths, filepath.Join(dir, file.Name()))
        }
        return paths
}

 

input 디렉토리 파일 예시 (testFile0.txt, testFile1.txt, testFile2.txt):

 

각 파일은 "testFileN\nfoo" 와 같은 내용을 포함합니다. (N은 파일 번호)

테스트 패턴

다음은 제시된 세 가지 테스트 패턴에 대한 자세한 설명입니다.

 

1. 파일 시스템 추상화를 통한 단위 테스트:

 

이 방법은 파일 시스템과의 상호작용을 최소화하여 insert 함수 자체의 로직만을 테스트합니다.

io.Readerio.Writer 인터페이스를 활용하여 파일 입출력 부분을 모의(mock)합니다.

 

이를 통해 파일 시스템의 의존성을 제거하고, 빠르고 안정적인 단위 테스트를 수행할 수 있습니다.

package main

import (
        "bytes"
        "testing"
)

func TestInsert(t *testing.T) {
        in := bytes.NewBufferString("testFile\nfoo")
        out := new(bytes.Buffer)
        insert(in, out)
        expected := []byte("testFile\nfoo\nbar")
        if !bytes.Equal(expected, out.Bytes()) {
                t.Fatalf("Unexpected output: expected %s, got %s", expected, out.Bytes())
        }
}

 

장점:

  • 간결하고 빠른 테스트 실행
  • 파일 시스템 오류로 인한 테스트 실패 방지
  • 단위 테스트에 집중하여 코드 가독성 및 유지보수성 향상

단점:

  • 파일 시스템 관련 로직(파일 경로 처리, 디렉토리 생성 등)은 테스트하지 않음
  • 전체적인 파일 처리 과정을 검증할 수 없음

2. 통합 테스트 (임시 디렉토리 활용):

이 방법은 파일 시스템과의 상호작용을 포함하여 insertAll 함수의 전체적인 기능을 테스트합니다.

 

테스트 전용 임시 디렉토리를 생성하고, 테스트 후 삭제하여 테스트 환경을 깨끗하게 유지합니다.

 

os.TempDir() 함수를 사용하여 임시 디렉토리를 생성하고, io/ioutil 패키지의 기능을 사용하여 파일 생성 및 삭제를 수행합니다.

package main

import (
        "io/ioutil"
        "os"
        "path/filepath"
        "testing"
)

func TestInsertAll(t *testing.T) {
        tempDir, err := ioutil.TempDir("", "test-files")
        if err != nil {
                t.Fatal(err)
        }
        defer os.RemoveAll(tempDir)

        inputDir := filepath.Join(tempDir, "input")
        outputDir := filepath.Join(tempDir, "output")

        os.MkdirAll(inputDir, 0777)
        ioutil.WriteFile(filepath.Join(inputDir, "testFile0.txt"), []byte("testFile0\nfoo"), 0644)


        insertAll(inputDir, outputDir)

        // output 파일 검증
        outputFiles, err := ioutil.ReadDir(outputDir)
        if err != nil {
                t.Fatal(err)
        }
        if len(outputFiles) != 1 {
                t.Errorf("Unexpected number of output files: expected 1, got %d", len(outputFiles))
        }

        // 파일 내용 검증 (예시: testFile0.txt)
        content, err := ioutil.ReadFile(filepath.Join(outputDir, "testFile0.txt"))
        if err != nil {
                t.Fatal(err)
        }
        expectedContent := []byte("testFile0\nfoo\nbar")
        if !bytes.Equal(content, expectedContent) {
                t.Errorf("Unexpected content: expected %s, got %s", expectedContent, content)
        }
}

 

장점:

  • 파일 시스템과의 상호작용을 포함한 통합 테스트 가능
  • 실제 파일 처리 과정을 검증하여 신뢰성 향상

단점:

  • 테스트 실행 속도가 상대적으로 느림
  • 파일 시스템 오류에 대한 처리 필요
  • 테스트 환경 설정 및 정리 과정 필요

3. 가상 파일 시스템(Afero) 활용:

Afero 라이브러리는 Go에서 가상 파일 시스템을 제공하여 파일 시스템과의 상호작용을 모의(mock)할 수 있도록 해줍니다.

 

메모리 상에서 파일 시스템을 에뮬레이트하여 빠르고 안정적인 테스트를 수행할 수 있으며, 실제 파일 시스템에 대한 접근을 제어하여 테스트 환경을 안전하게 관리할 수 있습니다.

package main

import (
        "bytes"
        "path/filepath"
        "testing"

        "github.com/spf13/afero"
)

func TestInsertAllAfero(t *testing.T) {
        appFs := afero.NewMemMapFs()
        inputDir := "/input"
        outputDir := "/output"
        afero.WriteFile(appFs, filepath.Join(inputDir, "testFile0.txt"), []byte("testFile0\nfoo"), 0644)

        insertAllAfero(appFs, inputDir, outputDir)

        //output 파일 검증
        files, err := afero.ReadDir(appFs, outputDir)
        if err != nil {
                t.Fatal(err)
        }
        if len(files) != 1{
                t.Errorf("Unexpected number of output files: expected 1, got %d", len(files))
        }
        content, err := afero.ReadFile(appFs, filepath.Join(outputDir, "testFile0.txt"))
        if err != nil {
                t.Fatal(err)
        }
        expectedContent := []byte("testFile0\nfoo\nbar")
        if !bytes.Equal(content, expectedContent) {
                t.Errorf("Unexpected content: expected %s, got %s", expectedContent, content)
        }
}

func insertAllAfero(appFs afero.Fs, inputDir, outputDir string) {
        afero.Walk(appFs, inputDir, func(path string, info os.FileInfo, err error) error {
                if !info.IsDir() {
                        outputFile := filepath.Join(outputDir, filepath.Base(path))
                        r, err := appFs.Open(path)
                        if err != nil {
                                return err
                        }
                        defer r.Close()
                        w, err := appFs.Create(outputFile)
                        if err != nil {
                                return err
                        }
                        defer w.Close()
                        insert(r, w)
                }
                return nil
        })
}

 

장점:

  • 빠르고 안정적인 테스트 실행
  • 실제 파일 시스템에 대한 영향 없음
  • 복잡한 파일 시스템 상호작용 시나리오에 대한 테스트 용이

단점:

  • Afero 라이브러리 추가 필요
  • 실제 파일 시스템과의 차이로 인한 예외적인 상황 발생 가능성 고려 필요

결론

각 테스트 패턴은 장단점이 있으며, 어떤 패턴을 선택할지는 테스트 대상 코드의 복잡성, 테스트 목표, 그리고 프로젝트의 요구사항에 따라 달라집니다.

 

단위 테스트와 통합 테스트를 적절히 조합하여 사용하는 것이 일반적이며, 복잡한 파일 처리 로직의 경우 Afero 라이브러리 활용을 고려하는것이 효율적입니다.

 

본 글에서 제시된 예시 코드와 설명을 바탕으로, 여러분의 프로젝트에 가장 적합한 테스트 전략을 선택하시기 바랍니다.