본문 바로가기
프로그래밍 언어 실전 가이드

루아 입문 시리즈 #15: 루아 테스팅과 CI/CD

by devcomet 2025. 7. 3.
728x90
반응형

루아 테스팅과 CI/CD 완벽 가이드 - 단위 테스트부터
루아 테스팅과 CI/CD 완벽 가이드 - 단위 테스트부터

 

루아 개발 프로젝트의 품질을 보장하고 자동화된 배포 환경을 구축하기 위한 단위 테스트, 통합 테스트, CI/CD 파이프라인 구축 방법을 상세히 알아보겠습니다.


루아 테스팅 개요

루아 프로젝트의 안정성과 품질을 보장하기 위해서는 체계적인 테스팅 전략이 필수입니다.

테스팅은 단순히 버그를 찾는 것을 넘어서 코드의 신뢰성을 높이고 리팩토링 시 안전망 역할을 합니다.

루아는 동적 타입 언어이기 때문에 런타임 에러를 방지하기 위한 테스팅이 더욱 중요합니다.

현대적인 루아 개발에서는 다양한 테스팅 프레임워크와 도구들이 활용되고 있습니다.


루아 테스팅 프레임워크 소개

Busted - 강력한 BDD 스타일 테스팅 프레임워크

Busted는 루아 커뮤니티에서 가장 인기 있는 테스팅 프레임워크입니다.

BDD(Behavior-Driven Development) 스타일의 테스트 작성을 지원하며, 직관적인 문법을 제공합니다.

-- calculator.lua
local Calculator = {}

function Calculator:new()
    local instance = {}
    setmetatable(instance, self)
    self.__index = self
    return instance
end

function Calculator:add(a, b)
    return a + b
end

function Calculator:divide(a, b)
    if b == 0 then
        error("Division by zero")
    end
    return a / b
end

return Calculator
-- spec/calculator_spec.lua
local Calculator = require("calculator")

describe("Calculator", function()
    local calc

    before_each(function()
        calc = Calculator:new()
    end)

    describe("addition", function()
        it("should add two positive numbers", function()
            assert.are.equal(5, calc:add(2, 3))
        end)

        it("should handle negative numbers", function()
            assert.are.equal(-1, calc:add(-3, 2))
        end)
    end)

    describe("division", function()
        it("should divide two numbers", function()
            assert.are.equal(2, calc:divide(10, 5))
        end)

        it("should throw error on division by zero", function()
            assert.has_error(function()
                calc:divide(10, 0)
            end, "Division by zero")
        end)
    end)
end)

 

Busted 테스트 프레임워크 실행 결과 화면 - 루아 단위 테스트 성공 사례
Busted 테스트 실행 결과 스크린샷

LuaUnit - 경량 xUnit 스타일 프레임워크

LuaUnit은 전통적인 xUnit 스타일의 테스트 작성을 지원합니다.

단일 파일로 구성되어 있어 의존성이 적고 설치가 간단합니다.

-- test_calculator.lua
local lu = require('luaunit')
local Calculator = require('calculator')

TestCalculator = {}

function TestCalculator:setUp()
    self.calc = Calculator:new()
end

function TestCalculator:testAdd()
    lu.assertEquals(self.calc:add(2, 3), 5)
    lu.assertEquals(self.calc:add(-1, 1), 0)
end

function TestCalculator:testDivide()
    lu.assertEquals(self.calc:divide(10, 2), 5)
    lu.assertError(function() self.calc:divide(10, 0) end)
end

os.exit(lu.LuaUnit.run())

단위 테스트 작성 방법

테스트 구조화와 명명 규칙

단위 테스트는 AAA 패턴(Arrange-Act-Assert)을 따라 구조화합니다.

describe("UserService", function()
    describe("when creating a new user", function()
        it("should generate unique user ID", function()
            -- Arrange
            local userService = UserService:new()
            local userData = {name = "John", email = "john@example.com"}

            -- Act
            local user = userService:createUser(userData)

            -- Assert
            assert.is_not_nil(user.id)
            assert.is_string(user.id)
            assert.are.equal(user.name, "John")
        end)
    end)
end)

모킹과 스텁 활용

외부 의존성을 가진 코드를 테스트할 때는 모킹을 활용합니다.

-- 데이터베이스 모킹 예제
local mock = require("luassert.mock")

describe("UserRepository", function()
    local db_mock
    local userRepo

    before_each(function()
        db_mock = mock({
            query = function() end,
            execute = function() end
        })
        userRepo = UserRepository:new(db_mock)
    end)

    after_each(function()
        mock.revert(db_mock)
    end)

    it("should save user to database", function()
        local user = {name = "Alice", email = "alice@example.com"}

        userRepo:save(user)

        assert.spy(db_mock.execute).was_called_with(
            match.is_string(),
            match.is_table()
        )
    end)
end)

통합 테스트 구현

API 엔드포인트 테스트

웹 애플리케이션의 통합 테스트는 실제 HTTP 요청을 시뮬레이션합니다.

-- OpenResty/Kong 환경에서의 API 테스트
local http = require("resty.http")
local cjson = require("cjson")

describe("User API", function()
    local httpc
    local base_url = "http://localhost:8080"

    before_each(function()
        httpc = http.new()
    end)

    after_each(function()
        httpc:close()
    end)

    describe("POST /api/users", function()
        it("should create a new user", function()
            local user_data = {
                name = "Test User",
                email = "test@example.com"
            }

            local res, err = httpc:request_uri(base_url .. "/api/users", {
                method = "POST",
                body = cjson.encode(user_data),
                headers = {
                    ["Content-Type"] = "application/json"
                }
            })

            assert.is_nil(err)
            assert.are.equal(201, res.status)

            local response_data = cjson.decode(res.body)
            assert.is_not_nil(response_data.id)
            assert.are.equal(user_data.name, response_data.name)
        end)
    end)
end)

데이터베이스 통합 테스트

실제 데이터베이스와의 상호작용을 테스트합니다.

-- PostgreSQL 통합 테스트 예제
local pgmoon = require("pgmoon")

describe("Database Integration", function()
    local db

    before_each(function()
        db = pgmoon.new({
            host = "localhost",
            port = "5432",
            database = "test_db",
            user = "test_user",
            password = "test_pass"
        })
        assert(db:connect())

        -- 테스트 데이터 초기화
        db:query("TRUNCATE users CASCADE")
    end)

    after_each(function()
        db:disconnect()
    end)

    it("should insert and retrieve user", function()
        local insert_sql = [[
            INSERT INTO users (name, email) 
            VALUES ('John Doe', 'john@example.com') 
            RETURNING id
        ]]

        local result = db:query(insert_sql)
        assert.is_not_nil(result[1].id)

        local select_sql = "SELECT * FROM users WHERE id = " .. result[1].id
        local user = db:query(select_sql)

        assert.are.equal('John Doe', user[1].name)
        assert.are.equal('john@example.com', user[1].email)
    end)
end)

 

루아 통합 테스트 실행 과정 다이어그램 - API부터 데이터베이스까지의 테스트 플로우
통합 테스트 실행 과정 다이어그램


테스트 코드 최적화

테스트 데이터 관리

효율적인 테스트를 위해 테스트 데이터를 체계적으로 관리합니다.

-- test_data.lua
local TestData = {}

TestData.users = {
    valid_user = {
        name = "John Doe",
        email = "john@example.com",
        age = 30
    },
    invalid_user = {
        name = "",
        email = "invalid-email",
        age = -5
    }
}

TestData.products = {
    laptop = {
        name = "MacBook Pro",
        price = 2000,
        category = "Electronics"
    },
    book = {
        name = "Lua Programming",
        price = 50,
        category = "Books"
    }
}

return TestData

헬퍼 함수 활용

반복적인 테스트 코드를 줄이기 위해 헬퍼 함수를 작성합니다.

-- test_helpers.lua
local TestHelpers = {}

function TestHelpers.create_test_user(overrides)
    local default_user = {
        name = "Test User",
        email = "test@example.com",
        created_at = os.time()
    }

    if overrides then
        for k, v in pairs(overrides) do
            default_user[k] = v
        end
    end

    return default_user
end

function TestHelpers.assert_user_fields(user)
    assert.is_string(user.name)
    assert.is_string(user.email)
    assert.is_number(user.created_at)
    assert.matches("@", user.email)
end

return TestHelpers

CI/CD 파이프라인 구축

GitHub Actions를 활용한 CI/CD

GitHub Actions를 사용하여 루아 프로젝트의 CI/CD 파이프라인을 구축합니다.

# .github/workflows/ci.yml
name: CI/CD Pipeline

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        lua-version: [5.1, 5.2, 5.3, 5.4, luajit]

    steps:
    - uses: actions/checkout@v3

    - name: Setup Lua
      uses: leafo/gh-actions-lua@v9
      with:
        luaVersion: ${{ matrix.lua-version }}

    - name: Setup LuaRocks
      uses: leafo/gh-actions-luarocks@v4

    - name: Install dependencies
      run: |
        luarocks install busted
        luarocks install luacov
        luarocks install --only-deps rockspec/*.rockspec

    - name: Run tests
      run: |
        busted --coverage
        luacov -r lcov

    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v3
      with:
        file: ./luacov.report.out
        flags: unittests
        name: codecov-umbrella

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
    - uses: actions/checkout@v3

    - name: Deploy to production
      run: |
        echo "Deploying to production..."
        # 실제 배포 스크립트 실행

Docker를 활용한 컨테이너화

루아 애플리케이션을 Docker 컨테이너로 패키징합니다.

# Dockerfile
FROM openresty/openresty:alpine

# 의존성 설치
RUN apk add --no-cache git make gcc musl-dev

# LuaRocks 설치
RUN /usr/local/openresty/luajit/bin/luarocks install busted
RUN /usr/local/openresty/luajit/bin/luarocks install luacov

# 애플리케이션 코드 복사
COPY . /app
WORKDIR /app

# 의존성 설치
RUN /usr/local/openresty/luajit/bin/luarocks install --only-deps rockspec/*.rockspec

# 테스트 실행
RUN /usr/local/openresty/luajit/bin/busted

# 애플리케이션 실행
CMD ["/usr/local/openresty/bin/openresty", "-g", "daemon off;"]

자동화 배포 전략

블루-그린 배포

무중단 배포를 위한 블루-그린 배포 전략을 구현합니다.

┌─────────────────────────────────────────────────────────────────┐
│                        Load Balancer                           │
│                     (nginx/HAProxy)                            │
└─────────────────────┬───────────────────────────────────────────┘
                      │
                      ▼
      ┌───────────────────────────────────────────────────────────┐
      │                Traffic Switching                          │
      │            (currently pointing to Blue)                   │
      └─────────────────┬─────────────────────────────────────────┘
                        │
        ┌───────────────┴───────────────┐
        ▼                               ▼
┌─────────────────┐               ┌─────────────────┐
│  Blue Environment│               │ Green Environment│
│  (Production)   │               │  (Staging)      │
│                 │               │                 │
│ ┌─────────────┐ │               │ ┌─────────────┐ │
│ │ App v1.0    │ │               │ │ App v1.1    │ │
│ │ (Active)    │ │               │ │ (Deploying) │ │
│ └─────────────┘ │               │ └─────────────┘ │
│                 │               │                 │
│ ┌─────────────┐ │               │ ┌─────────────┐ │
│ │ Database    │ │               │ │ Database    │ │
│ │ Connection  │ │               │ │ Connection  │ │
│ └─────────────┘ │               │ └─────────────┘ │
└─────────────────┘               └─────────────────┘
        │                               │
        │                               │
        ▼                               ▼
┌─────────────────┐               ┌─────────────────┐
│ Health Check    │               │ Health Check    │
│ Status: OK      │               │ Status: Testing │
└─────────────────┘               └─────────────────┘

배포 프로세스:
1. Green 환경에 새 버전 배포
2. Green 환경 헬스 체크 수행
3. 문제없으면 트래픽을 Green으로 전환
4. Blue 환경은 롤백용으로 유지
-- deployment_manager.lua
local DeploymentManager = {}

function DeploymentManager:new(config)
    local instance = {
        config = config,
        current_env = "blue",
        nginx_config_path = "/etc/nginx/conf.d/app.conf"
    }
    setmetatable(instance, self)
    self.__index = self
    return instance
end

function DeploymentManager:deploy(version)
    local target_env = self.current_env == "blue" and "green" or "blue"

    -- 새 버전을 타겟 환경에 배포
    self:deploy_to_environment(target_env, version)

    -- 헬스 체크 수행
    if self:health_check(target_env) then
        -- 트래픽 스위칭
        self:switch_traffic(target_env)
        self.current_env = target_env
        return true
    else
        error("Health check failed for " .. target_env)
    end
end

function DeploymentManager:health_check(env)
    local http = require("resty.http")
    local httpc = http.new()

    local url = string.format("http://%s-app:8080/health", env)
    local res, err = httpc:request_uri(url, {
        method = "GET",
        timeout = 5000
    })

    return res and res.status == 200
end

return DeploymentManager

롤백 메커니즘

배포 실패 시 이전 버전으로 롤백하는 메커니즘을 구현합니다.

-- rollback_manager.lua
local RollbackManager = {}

function RollbackManager:new(deployment_manager)
    local instance = {
        deployment_manager = deployment_manager,
        version_history = {}
    }
    setmetatable(instance, self)
    self.__index = self
    return instance
end

function RollbackManager:record_deployment(version, timestamp)
    table.insert(self.version_history, {
        version = version,
        timestamp = timestamp,
        environment = self.deployment_manager.current_env
    })
end

function RollbackManager:rollback_to_previous()
    if #self.version_history < 2 then
        error("No previous version to rollback to")
    end

    local previous_version = self.version_history[#self.version_history - 1]
    self.deployment_manager:deploy(previous_version.version)

    -- 롤백 후 현재 버전 제거
    table.remove(self.version_history)
end

return RollbackManager

 

루아 CI/CD 파이프라인 플로우 다이어그램 - GitHub Actions와 Docker를 활용한 자동화 배포
CI/CD 파이프라인 플로우 다이어그램


테스팅 도구 비교

다양한 루아 테스팅 도구들의 특징을 비교해보겠습니다.

도구 스타일 장점 단점 사용 케이스
Busted BDD 풍부한 기능, 모킹 지원 의존성 많음 복잡한 프로젝트
LuaUnit xUnit 경량, 단순함 기능 제한적 소규모 프로젝트
Telescope TDD 직관적 문법 개발 중단 레거시 프로젝트
Shake 커스텀 유연성 높음 학습 곡선 특수 요구사항

성능 테스트와 부하 테스트

LuaJIT 성능 테스트

루아 코드의 성능을 측정하고 최적화합니다.

-- performance_test.lua
local function benchmark(func, iterations)
    local start_time = os.clock()

    for i = 1, iterations do
        func()
    end

    local end_time = os.clock()
    return end_time - start_time
end

describe("Performance Tests", function()
    it("should perform string concatenation efficiently", function()
        local test_func = function()
            local result = ""
            for i = 1, 1000 do
                result = result .. "test"
            end
            return result
        end

        local time_taken = benchmark(test_func, 100)
        assert.is_true(time_taken < 1.0) -- 1초 이내
    end)

    it("should handle table operations efficiently", function()
        local test_func = function()
            local t = {}
            for i = 1, 10000 do
                t[i] = i * 2
            end
            return t
        end

        local time_taken = benchmark(test_func, 10)
        assert.is_true(time_taken < 0.5) -- 0.5초 이내
    end)
end)

부하 테스트 도구 활용

웹 애플리케이션의 부하 테스트를 위해 wrk나 Apache Bench를 활용합니다.

# wrk를 사용한 부하 테스트
wrk -t12 -c400 -d30s --script=load_test.lua http://localhost:8080/api/users

# Apache Bench를 사용한 부하 테스트
ab -n 10000 -c 100 http://localhost:8080/api/users

모니터링과 로깅

애플리케이션 모니터링

프로덕션 환경에서의 애플리케이션 상태를 모니터링합니다.

-- monitoring.lua
local Monitoring = {}

function Monitoring:new(config)
    local instance = {
        config = config,
        metrics = {
            request_count = 0,
            error_count = 0,
            response_times = {}
        }
    }
    setmetatable(instance, self)
    self.__index = self
    return instance
end

function Monitoring:record_request(response_time, status_code)
    self.metrics.request_count = self.metrics.request_count + 1
    table.insert(self.metrics.response_times, response_time)

    if status_code >= 400 then
        self.metrics.error_count = self.metrics.error_count + 1
    end
end

function Monitoring:get_health_status()
    local avg_response_time = self:calculate_average_response_time()
    local error_rate = self.metrics.error_count / self.metrics.request_count

    return {
        status = error_rate < 0.05 and "healthy" or "unhealthy",
        request_count = self.metrics.request_count,
        error_rate = error_rate,
        avg_response_time = avg_response_time
    }
end

return Monitoring

구조화된 로깅

효율적인 디버깅을 위해 구조화된 로깅을 구현합니다.

-- logger.lua
local cjson = require("cjson")

local Logger = {}

function Logger:new(level)
    local instance = {
        level = level or "INFO",
        levels = {
            DEBUG = 1,
            INFO = 2,
            WARN = 3,
            ERROR = 4
        }
    }
    setmetatable(instance, self)
    self.__index = self
    return instance
end

function Logger:log(level, message, context)
    if self.levels[level] >= self.levels[self.level] then
        local log_entry = {
            timestamp = os.date("%Y-%m-%dT%H:%M:%S"),
            level = level,
            message = message,
            context = context or {}
        }

        print(cjson.encode(log_entry))
    end
end

function Logger:info(message, context)
    self:log("INFO", message, context)
end

function Logger:error(message, context)
    self:log("ERROR", message, context)
end

return Logger

보안 테스트

입력 검증 테스트

악의적인 입력에 대한 보안 테스트를 수행합니다.

-- security_test.lua
describe("Security Tests", function()
    local userService = UserService:new()

    describe("Input Validation", function()
        it("should prevent SQL injection", function()
            local malicious_input = "'; DROP TABLE users; --"

            assert.has_error(function()
                userService:findUserByName(malicious_input)
            end)
        end)

        it("should prevent XSS attacks", function()
            local xss_payload = "<script>alert('XSS')</script>"
            local user = userService:createUser({
                name = xss_payload,
                email = "test@example.com"
            })

            assert.is_false(string.find(user.name, "<script>"))
        end)
    end)
end)

테스트 커버리지 분석

코드 커버리지 측정

luacov를 사용하여 테스트 커버리지를 측정합니다.

-- .luacov 설정 파일
return {
    statsfile = "luacov.stats.out",
    reportfile = "luacov.report.out",
    include = {
        "src/",
        "lib/"
    },
    exclude = {
        "spec/",
        "test/"
    }
}

커버리지 리포트를 통해 테스트되지 않은 코드를 식별하고 개선합니다.


지속적인 개선

테스트 리팩토링

테스트 코드도 지속적으로 리팩토링하여 유지보수성을 높입니다.

-- 리팩토링 전
describe("User Management", function()
    it("should create user with valid data", function()
        local user = UserService:createUser({
            name = "John",
            email = "john@example.com",
            age = 30
        })
        assert.is_not_nil(user.id)
        assert.are.equal(user.name, "John")
        assert.are.equal(user.email, "john@example.com")
        assert.are.equal(user.age, 30)
    end)
end)

-- 리팩토링 후
describe("User Management", function()
    local function create_valid_user(overrides)
        local default_data = {
            name = "John",
            email = "john@example.com",
            age = 30
        }
        return UserService:createUser(merge_tables(default_data, overrides or {}))
    end

    it("should create user with valid data", function()
        local user = create_valid_user()
        assert_valid_user(user, {name = "John", email = "john@example.com", age = 30})
    end)
end)

결론

루아 프로젝트의 테스팅과 CI/CD 구축은 고품질 소프트웨어 개발의 핵심입니다.

Busted나 LuaUnit과 같은 테스팅 프레임워크를 활용하여 단위 테스트와 통합 테스트를 체계적으로 구현할 수 있습니다.

GitHub Actions와 Docker를 활용한 CI/CD 파이프라인은 자동화된 배포와 품질 관리를 가능하게 합니다.

지속적인 테스트와 모니터링을 통해 안정적이고 신뢰할 수 있는 루아 애플리케이션을 구축할 수 있습니다.

다음 시리즈에서는 루아의 고급 메타프로그래밍 기법에 대해 알아보겠습니다.


참고 자료:

 

루아 입문 시리즈 #14: 루아 성능 최적화와 프로파일링

루아 애플리케이션의 성능을 극대화하기 위한 메모리 관리, JIT 컴파일 활용, 병목 지점 분석 등 실전 최적화 기법을 단계별로 학습하고 적용해보세요.들어가며루아(Lua)는 가벼우면서도 강력한

notavoid.tistory.com

 

728x90
반응형