Tags: , , ,

Categories:

4 minute read

스트레스 테스트


이전 소프트웨어 마에스트로 내부 대회에서 알고리즘 문제를 출제하면서 코드포스 폴리곤을 이용했었다. 폴리곤에는 엄밀한 검증을 위한 여러가지 툴들을 제공하는데, 그 중에 하나가 스트레스 테스트였다. 스트레스 테스트란 정답 코드와 검증이 완료되지 않은 코드를 랜덤으로 틀린 경우가 나올 때까지 실행시키는 테스트를 말한다. 사실 써볼일은 없어서 사용해보진 않았는데 대충 다음과 같은 절차로 진행되는 것 같았다.

  1. 테스트케이스를 만들 generator 등록
  2. 테스트케이스 검증을 위한 validator 등록
  3. 두 결과물 비교를 위한 checker 등록
  4. 두 솔루션 선택 및 시간 제한 설정
  5. stress 시작

이를 좀 모방해서 직접 자동화하는 방식으로 해보면 어떨까 생각만 하고 있었다. 이번에 수열과 쿼리 27문제를 풀이하면서 이 테스트 방식으로 코너케이스를 찾았고 AC를 받을 수 있었는데, 포스팅을 진행하고자 한다.

준비물

아래는 챙겨야할 준비물이다.

  1. bash 터미널
  2. gcc 컴파일러
  3. AC는 받을 수 없으나 작은 제한에서 58000% 확률로 맞을 수 있는 솔루션 코드

  4. (옵션) make

물론 윈도우 환경에서 powerShell이나 msvc 컴파일러를 다룰 수 있다면 그것으로 해도 전혀 상관없다. 이 글에서는 위 세 가지를 가지고 있다는 가정하에 진행한다. 또, make는 컴파일 과정을 좀 편하게 해주기 때문에 써주었는데, 컴파일이야 손수 다 해주면 되기 때문에 전적으로 옵션이다.

파일 구조는 아래와 같이 해주었다.

stress-test
├── 1.cpp
├── 2.cpp
├── Makefile
├── test.cpp
└── testcase.cpp

1.cpp2.cpp는 비교할 두 솔루션 코드이고 하나는 반드시 정답을 보이는 정답코드이다. test.cpp는 테스트를 돌려주는 메인함수, testcase.cpp는 테스트 케이스를 만드는 프로그램이다.

쉘 스크립트를 짜듯이 자동화 스크립트를 만드는 것과 사실 동일한데, 비교 함수 작성들을 감안하면 프로그램 언어로 짜는게 좋을 것 같아 C++로 진행했다.

Makefile

CC=g++
CFLAGS=-fdiagnostics-color=always -g -Wall -O2

all: $(patsubst %.cpp,%.out,$(wildcard ./*.cpp))

%.out: %.cpp
	$(CC) $(CFLAGS) -o $@ $<

clean:
	rm ./*.out

make는 어디까지나 옵션이기에 그냥 듣고 흘려도 괜찮다.

CC=g++
CFLAGS=-fdiagnostics-color=always -g -Wall -O2
  1. 컴파일러를 g++로 지정한다.
  2. 컴파일 옵션을 -fdiagnostics-color=always -g -Wall -O2 준다.

각 컴파일 옵션에 대해서는 사실 크게 중요하진 않고, 문제풀이에서는 웬만하면 똑같다. 궁금하면 검색해보자.

all: $(patsubst %.cpp,%.out,$(wildcard ./*.cpp))

patsubst은 특정 패턴을 교체할 때 사용하는 make 함수인데, $(wildcard ./*.cpp)파일로 1.cpp 2.cpp test.cpp testcase.cpp을 가져오면 %.cpp 패턴을 인식해서 %.out 패턴으로 바꿔준다. 따라서 1.out 2.out test.out testcase.out 이랑 똑같아진다. 귀찮은데 하드코딩하는게 더 나을지도 모르겠다.

%.out: %.cpp
	$(CC) $(CFLAGS) -o $@ $<

%.out 패턴에 해당하는 명령을 인식하면 %.cpp를 의존으로 하는 다음 명령을 실행한다는 의미의 구문이다. test.out이 호출되면 test.out: test.cpp가 된다. $@test.out, $<test.cpp가 되어 실질적으로 컴파일이 진행된다.

따라서 위 모든 코드는 그저 다음의 명령어를 자동으로 수행해줄 뿐이다.

g++ -fdiagnostics-color=always -g -Wall -O2 -o 1.out test.cpp
g++ -fdiagnostics-color=always -g -Wall -O2 -o 2.out test.cpp
g++ -fdiagnostics-color=always -g -Wall -O2 -o test.out test.cpp
g++ -fdiagnostics-color=always -g -Wall -O2 -o testcase.out testcase.cpp

make는 각 파일별로 의존성을 파악해주기 때문에, 수정한 파일에 대해서만 컴파일을 진행할 수 있고, 불필요한 자원 낭비를 방지할 수 있다. 1.cpp 파일만 수정하면 알아서 1.out에 대해서만 컴파일을 진행한다.

clean:
	rm ./*.out

make clean은 만들어진 .out파일을 모두 제거해준다. 자동으로 해두면 여러모로 편리하다. 여하튼 make는 완전히 옵션이고 잘 모르겠으면 다음으로 넘어가도 좋다. 복사 붙여넣기 하자

test.cpp

#include <stdlib.h>
#include <string>
#include <iostream>
#include <fstream>

using namespace std;

void compile() {
    // system("g++ -g -O2 -Wall 1.cpp -o 1.out");
    // system("g++ -g -O2 -Wall 2.cpp -o 2.out");
    // system("g++ -g -O2 -Wall testcase.cpp -o testcase.out");
}

void make_testcase() {
    system("./testcase.out > testcase.txt");
}

void run_test() {
    system("./1.out < testcase.txt > 1.o");
    system("./2.out < testcase.txt > 2.o");
}

bool compare() {
    ifstream fcin1, fcin2;
    fcin1.open("1.o");
    fcin2.open("2.o");
    // system("diff -c 1.o 2.o");

    string R1, R2;
    bool flag = 1;
    while (getline(fcin1, R1) && getline(fcin2, R2)) {
        if (R1 != R2) {
            flag = 0; break;
        }
    }
    if (getline(fcin1, R1) || getline(fcin2, R2))
        flag = 0;
    fcin1.close();
    fcin2.close();
    return flag;
}

int main() {
    compile();
    
    int i = 1;
    do {
        cout << i++ << '\n';
        make_testcase();
        run_test();
    } while (compare());
}

사실 코드는 짜보면 간단한데, 왠지 모를 뇌정지가 오는 부분들이 조금 있다. 각 테스트는 항상 실행을 먼저 해줘야하므로 do while문을 이용했으며, 프로그램이 돌아가는지 안돌아가는지 답답해서 i값을 찍어주어 몇번째 테스트케이스인지 보이도록 했다. 그 외에 설명이 필요한 부분만 약간씩 하도록 하겠다.

void compile() {
    // system("g++ -g -O2 -Wall 1.cpp -o 1.out");
    // system("g++ -g -O2 -Wall 2.cpp -o 2.out");
    // system("g++ -g -O2 -Wall testcase.cpp -o testcase.out");
}

컴파일 부분이다. make를 사용하지 않는다면 system 함수를 이용해 컴파일을 수행하는 쉘 명령어를 실행시키자. 컴파일은 한 번만 진행하면 되기 때문에 main()함수에서 시작과 동시에 한 번만 실행시킨다.

void make_testcase() {
    system("./testcase.out > testcase.txt");
}

void run_test() {
    system("./1.out < testcase.txt > 1.o");
    system("./2.out < testcase.txt > 2.o");
}

마찬가지로 테스트케이스 생성과 첫번째, 두번째 솔루션을 실행시키는 쉘 명령어인데, <execute-file> > <file> 문법은 execute-file을 실행시켜 나오는 표준입출력을 file로 리다이렉션하는 것이다. 즉, 결과물이 파일로 출력된다.

bool compare() {
    ifstream fcin1, fcin2;
    fcin1.open("1.o");
    fcin2.open("2.o");
    system("diff -c 1.o 2.o");

    string R1, R2;
    bool flag = 1;
    while (getline(fcin1, R1) && getline(fcin2, R2)) {
        if (R1 != R2) {
            flag = 0; break;
        }
    }
    if (getline(fcin1, R1) || getline(fcin2, R2))
        flag = 0;
    fcin1.close();
    fcin2.close();
    return flag;
}

두 파일을 비교하는 함수이다. 하나씩 줄을 읽어와서 비교하며, 파일 끝까지 진행되었을때 더 얻게되는 문자열이 있는지 비교하고 종료한다. 다만 비교하고 어떤 부분이 다른지는 배쉬에 있는 diff -c <file1> <file2> 명령어를 사용하면 간단하고 예쁘게 출력할 수 있다. 나는 귀찮아서 직접 구현하지 않고, 이를 이용해 출력했다.

실행

make를 이용한다면 이미 모두 컴파일이 되어있을 것이고, 아니라면 test.cpp를 컴파일해주자.

g++ -fdiagnostics-color=always -g -Wall -O2 -o test.out test.cpp 

컴파일이 완료되면 실행만하면 된다.

./test.out

단,

세그폴트 앞에서는 만인이 평등하니 주의하자.. 그냥 다시 풀자

아주 깔끔하게, 틀린 테스트 케이스를 찾을 수 있었다. 그리고 아직 testcase.txt에는 마지막 테스트케이스가 기록되어 있으므로 가져다 쓰면 될 것이다.

스트레스 테스트를 적극적으로 이용하는 것을 권장한다. 스스로 테스트 케이스를 만드는 것도 분명 중요하지만, 스스로 만들 수 없는 테스트 케이스도 분명 있기 때문이다. 지쳐서 만들기도 힘들다.. 계속 그렇게 거르지도 못하는 테스트 케이스를 만들다간 스트레스 받아서 탈모가 올지도 모른다..

자동화 스크립트는 파이썬이나 shell을 활용하는게 일반적인데, 파이썬은 속도가 느리고, shell은 숙련도가 떨어져서 사용할 엄두를 내질 못했다. 언젠가는 shell로 만들어봐야겠다.

Leave a comment