오늘은 지란지교시큐리티에 입사해 반복적인 테스트 업무의 효율을 높이기 위해 직접 웹 자동화 테스팅 툴을 직접 만들었다는 손정빈님을 만났어요. 본인뿐 만 아니라 팀의 업무 효율성을 높여준 프로세스, 어떻게 개발하고 구축했는지 이야기를 들어볼게요.
Q. 웹 자동화 테스팅 툴은 어떻게 구축하게 되셨어요?
저희 팀은 테스트 활동 시 변경사항 위주의 리스크 기반 테스팅과 경험 기반 기법의 테스트를 같이 활용합니다. 가시적으로 변경이 없어 보이는 부분도 신규, 변경사항으로 인해 사이드 이펙트 이슈가 발생할 수 있습니다. 신규, 변경 항목에 대한 테스트와 더불어 기존 기능에 대한 커버리지를 보장하기 위해 테스트 자동화를 구축하였습니다.
웹 애플리케이션의 자동화 도구는 여러 가지가 있습니다. 웹 브라우저를 제어하는 대표적인 도구로 Selenium, Cypress, Playwright 등이 있습니다. 자동화 테스트는 이미 많은 기업에서도 활용되고 있고 수많은 설계 디자인 패턴이 존재합니다.
아래부터는 Java, Selenium, Junit, Gradle을 사용한 간단한 자동화 구조 설계와 함께 분산 빌드 과정에 대해 소개해 드리겠습니다. 사용되는 기술 스택 모두 깊이 들어가면 매우 방대하기 때문에 구조 설계 소개에 초점을 맞춰보겠습니다.
앞서 샘플 예제에서 사용되는 의존성 라이브러리를 build.gradle에 추가하겠습니다.
…dependencies {
testImplementation group: 'org.seleniumhq.selenium', name: 'selenium-java', version: '4.18.1'
testImplementation('org.slf4j:slf4j-simple:1.7.30')
testImplementation("org.junit.jupiter:junit-jupiter-api:5.7.1")
testImplementation("org.junit.jupiter:junit-jupiter-engine:5.7.1")
testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-params', version: '5.7.1'
testImplementation 'org.projectlombok:lombok:1.18.20'
testImplementation("org.junit.platform:junit-platform-suite:1.8.1")
testAnnotationProcessor group: 'org.projectlombok', name: 'lombok', version: '1.18.28'
}…
WebDriver 인터페이스 구현
기본 Selenium WebDriver에 정의된 메소들로는 다소 아쉬우므로 Selenium에서 제공하는WebDriver 인터페이스를 구현해서 기존 메소드를 오버라이드 하거나 필요한 메소드를 추가로 정의합니다. 저는 사용자 액션에 대한 모든 동작을 WebDriverWrapper 클래스에 구현했습니다.
public class WebDriverWrapper implements WebDriver {
private WebDriver webDriver;
public WebDriverWrapper(WebDriver webDriver) {
this.webDriver = webDriver;
}
@Override
public void get(String url) {
webDriver.get(url);
log.info(url);
}
public void click(WebElement element){
new WebDriverWait(webDriver, Duration.ofMillis(15000))
.until(ExpectedConditions.elementToBeClickable(element))
.click();
log.info("Click : " + element);
delay(500);
}
public void sendKeys(By by, CharSequence str){…}
public void sendKeys(WebElement element, CharSequence str){…}
public WebElement findTagText(String tagName, String text){…}
public String waitAlert(long maxWaitTime){…}
public void delay(long miliSeconds){…} /** By(id, name 등)요소 외 WebElement를 식별하는 메소드를 구현합니다. *//** 기타 WebDriver의 메소드를 오버라이드 합니다. */}
참고. 저는 Lombok을 사용하기 때문에 @Slf4j 어노테이션으로 로그를 출력합니다. 본래 용도는 주로 웹 개발에 사용되지만 @Builder, @Getter, @Setter의 구현이 손쉽습니다. 다른 클래스 예제에서도 롬복 활용을 하니 참고하시기 바랍니다.
Test Handler 인터페이스 구현
다음으로 테스트 도중 여러 상황에 대한 처리 동작들을 구현할 수 있습니다. 어노테이션으로 사용하기 위해 @interface 내부에 몇 가지 핸들러 인터페이스를 구현한 클래스를 정의하겠습니다.
TestExcutionExceptionHandler 는 @Test 메소드 내에 Exception에 대한 처리를 구현합니다. 저는 Exception이 발생했을 때 스크린 캡처를 하도록 구현하겠습니다.
TestWatcher는 테스트 결과에 대한 처리를 구현합니다. 만약 별도의 Test management API와 연동시키고자 할 때 테스트 결과 값에 대한 API 통신 동작을 구현할 수 있습니다.
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(CustomExtendsWithTest.CustomExtension.class)
public @interface CustomExtendsWithTest {
@Slf4j
public static class CustomExtension implements TestExecutionExceptionHandler,
TestWatcher {
@Override
public void handleTestExecutionException(ExtensionContext context, Throwable throwable) throws Throwable {
WebDriverModule.getInstance().screenCapture();
throw throwable;
}
@Override
public void testSuccessful(ExtensionContext context) {
// Testcase management 결과 기록
TestWatcher.super.testSuccessful(context);
}
@Override
public void testFailed(ExtensionContext context, Throwable cause) {
// Testcase management 결과 기록
TestWatcher.super.testFailed(context, cause);
}
}
}
참고1. @BeforeEach, @AfterEach, @BeforeAll, @AfterAll 등의 어노테이션 처리는LifecycleMethodExcutionExceptionHandler 인터페이스를 통해 구현합니다.
참고2. WebDriverModule은 WebDriver(e.g. ChromeDriver)를 로드하는 모듈입니다. 이 외 상황에 맞는 콜백 인터페이스를 찾아 상황에 맞는 처리 동작을 구현할 수 있으니 Junit, Selenium 레퍼런스를 참고하시기 바랍니다.
POM 클래스 정의
로그인 화면에 대한 POM(Page Object Model) 클래스를 정의해 보겠습니다. 로그인 페이지에 있는 요소와 동작들을 캡슐화하고 롬복을 사용하여 빌더 패턴으로 인스턴스를 생성할 수 있도록 구성해 보겠습니다.
@Builder
@Getter
@Setter
public class LoginPage {
private final WebDriverWrapper webDriver = WebDriverModule.getInstance().getWebDriver();
private String loginId;
private String loginPw;
@Builder.Default
private Language language = Language.Korean;
@Builder.Default
private boolean saveEmail = false;
public void login() {
// 로그인 페이지 이동
webDriver.get(Config.WebUrl + Config.Login.Login.getUrl());
// id, pw 입력
webDriver.sendKeys(By.className("id"), loginId);
webDriver.sendKeys(By.className("pw"), loginPw);
// E-mail 저장 여부
WebElement saveEl = webDriver.findTagText("label", "E-mail 저장");
if(saveEmail){
if(!saveEl.isSelected()) {
webDriver.click(saveEl);
}
}else{
if(saveEl.isSelected()) {
webDriver.click(saveEl);
}
}
// Login 버튼
webDriver.click(webDriver.findTagText("button", "LOGIN"));
}
public void changeLanguage(Language language) { this.language = language;
new Select(webDriver.findElement(By.name("lang"))) .selectByValue(language.value);
}
public enum Language {
Korean ("ko"),
English ("en"),
Japanese ("ja");
private final String value;
Language(String value) {
this.value = value;
}
public String getValue(){
return value;
}
}
}
참고. 멤버 변수의 초기값 설정이 필요할 경우 @Builder.Defeault 를 선언합니다. 실제 테스트케이스에선 @Test 메소드 완료 후 @AfterEach 메소드 단계에서 다른 기능 테스트에 영향이 없도록 변경한 설정값을 초기값으로 복원합니다.
TestCase 클래스 작성
로그인 POM 클래스를 정의했으니 로그인 화면에 대한 간단한 테스트 케이스를 작성해 보겠습니다. 앞서 구현한 핸들러 클래스를 테스트케이스 클래스에 어노테이션으로 선언하면 하위 모든 @Test 메소드에 적용됩니다.
테스트케이스 클래스에서 상속받는 BaseTestCase 클래스엔 모든 테스트케이스에서 공통적으로 참조 가능한 요소 및 메소드를 정의합니다. static 메소드를 구현해야 되니 필요에 따라 구현하도록 합니다.
@Slf4j
@CustomExtendsWithPrecondition
@CustomExtendsWithTest
public class TC_LoginPage extends BaseTestCase{
private final WebDriverWrapper webDriver = WebDriverModule.getInstance().getWebDriver();
@BeforeEach
@Override
public void tearUp() {
super.tearUp();
webDriver.get(Config.WebUrl + Config.Login.Login.getUrl());
}
@AfterEach
@Override
public void tearDown() {
super.tearDown();
}
@DisplayName("사용자 로그인 테스트")@EnabledIf("com.example.Module.TestPlanManager#isLogin")
@ParameterizedTest
@ValueSource(strings = {"admin@test.com", "personal@test.com"})
public void Test_Login(String username) {
LoginPage.builder()
.loginId(username)
.loginPw(Config.common_password)
.build()
.login();
if(username.equals("admin@test.com")) {
// 관리자로 로그인했을때 테스트를 정의합니다.
}
else {
// 개인사용자로 로그인했을때 테스트를 정의합니다.
}
}
참고. Config 클래스에 메뉴별 URL, 관리자 비밀번호가 정의되어 있습니다.
@EnableIf는 선택적으로 Test 메소드를 진행할 수 있습니다. 실제 테스트 코드에선 데이터베이스(MySQL)의 연동을 위해 Hibernate를 사용하여 테스트 실행 여부(execute)의 값을 불러와 @EnableIf에 참조되도록 구현되어 있습니다. 테스트 코드, 빌드 스크립트의 변경이 아닌 간단한 DB 값 변경으로 테스트 제어를 위한 구조입니다.
샘플 코드에선 간단하게 클래스에 실행 여부를 관리하는 클래스에 정의하여 활용해 보겠습니다.
public class TestPlanManager {
static boolean login = true;
static boolean login_save_email = false;
static boolean login_language = false;
static boolean isLogin() { return login; }
static boolean isLogin_save_email() { return login_save_email; }
static boolean isLogin_language() { return login_language; }
}
위의 테스트케이스에선 관리자, 개인 사용자로 로그인 여부를 확인해 보았습니다.
제품 특성상 관리자 페이지의 많은 설정 화면이 On, Off 형식으로 구성되어 있어@ParameterizedTest의 EnumSource, ValueSource 등을 잘 활용하면 테스트 코드를 많이 줄일 수 있습니다.
@DisplayName("사용자 언어 테스트")@EnabledIf("com.example.Module.TestPlanManager#isLogin ")
@ParameterizedTest
@EnumSource
public void Test_Login_Language(LoginPage.Language language) {
if (language == LoginPage.Language.Korean) { // 언어가 한국어일때 테스트를 정의합니다. }
else if (language == LoginPage.Language.English) { // 언어가 영어일때 테스트를 정의합니다. }
else if (language == LoginPage.Language.Japanese) { // 언어가 일본어일때 테스트를 정의합니다. }
}
@DisplayName("이메일 저장 테스트")@EnabledIf("com.example.Module.TestPlanManager#isLogin_save_email")
@ParameterizedTest
@ValueSource(booleans = { true, false })
public void Test_Login_Save_Email(boolean use) {
if(use) { // 로그인 이메일을 저장시 테스트를 정의합니다. }
else { // 로그인 이메일을 미저장시 테스트를 정의합니다. }
}
현재 앞선 과정에서 구현된 샘플 소스 코드입니다.
참고. TC_Configuration은 아래 CI환경에서의 빌드 과정 설명을 위해 임의 생성하였습니다.
파이프라인 스크립트 작성
테스트케이스까지 만들었으니 빌드를 할 차례입니다. 위의 테스트 코드를 빌드 시 저희는 Jenkins를 활용하여 Linux에서 headless로 테스트가 진행됩니다. 테스트 시나리오를 계속 늘리면서 소요되는 빌드 시간도 증가하게 되었는데 이때 분산 테스트를 생각하게 되었습니다. Selenium Grid 가 존재하지만 제품 특성상 크로스 브라우징의 목적보단 테스트 분산에 더 목적을 두어 Jenkins pipeline을 사용하게 되었습니다.
아래 예제에선 pipeline script 구조에 대해서 알아보겠습니다. Pipeline 설정 시 젠킨스 환경 변수 및 노드 서버 설정, git credential 설정에 대한 내용은 제외하겠습니다.
1번의 테스터에 의해 수동으로 테스트를 시작합니다. 커밋, 푸시 단위의 동시다발적인 통합 UI 테스트를 진행하진 않습니다. Target Product(Product server) 당 독립된 빌드 서버로 짝지어 테스트를 진행하는 구조입니다. 병렬 테스트이기 때문에 1개의 Target Product를 여러 빌드 서버가 참조할 경우 간섭으로 인한 실패 사항을 방지하기 위한 구조입니다. 마지막 5번에선 결과 리포트 병합을 위해 Node2 서버의 테스트 결과 리소스를 Node1 서버로 전송하도록 구성되어 있습니다.
앞서 병렬 빌드 시 각각 빌드 서버(Node)에선 자신의 노드를 구분해야 할 상황이 있을 수 있습니다. build.gradle에 시스템 프로퍼티를 정의해 주도록 합니다.
test {
useJUnitPlatform()
systemProperty "buildType", System.getProperty("buildType")
systemProperty "nodeName", System.getProperty("nodeName")}
JVM 런타임에서 buildType과 nodeName의 값을 불러올 땐 System.getProperty를 호출합니다.
Config.nodeName = System.getProperty("nodeName");
Config.buildType = System.getProperty("buildType");
이후에 Gradle 빌드 명령어에 -D옵션을 추가해 빌드(Node) 별 구분자를 추가합니다.
본격적으로 파이프라인 스크립트를 살펴보겠습니다. stages는 파이프라인의 여러 단계를 정의하는 구간입니다. parallel 이하의 각 stage 부터 병렬 처리되는 구간입니다. 아래는 2개의 stage로 병렬 처리하며 만약 1개의 stage는 완료되었지만 1개의 stage가 완료되지 않았다면 나머지 stage는 대기하게 됩니다.
parallel 구간을 묶어주는 `Test` stage와 stages를 정의합니다. 아래는 `Node1`과 `Node2` 두 개의 stage로 병렬 처리하는 예시입니다.
// 각 노드의 IP는 Global 환경 변수에서 로드합니다.def node1_host = env.NODE1_IP
def node2_host = env.NODE2_IPpipeline {
tools { // gradle 버전을 기입합니다.
gradle 'gradle version'
}
stages {
stage('Test') {
parallel {
stage('Node1') {
agent {
label "${node1_host}"
}
steps {
script {
// 테스트 빌드 전 필요한 동작들을 정의합니다.
try {
// Build Command
sh "gradle clean build -DbuildType=pipeline -DnodeName=Node1 test \
--tests TC_LoginPage"
} catch (err) {
echo err.getMessage()
}
}
}
post {
always {
// 빌드가 진행된 이후 작업을 정의합니다.
// e.g. cp 명령어를 통해 Allure report를 생성할 경로로 리소스 파일을 이동합니다.
}
}
}
stage("Node2") {
agent {
label "${node2_host}"
}
steps {
script {
// 테스트 빌드 전 필요한 동작들을 정의합니다.
try {
// Build Command
sh "gradle clean build -DbuildType=pipeline -DnodeName=Node2 test \
--tests TC_Configuration"
} catch (err) {
echo err.getMessage()
}
}
}
post {
always {
// 빌드가 진행된 이후 작업을 정의합니다.
// e.g. scp 명령어를 통해 테스트 결과 리소스를 Node1으로 복사합니다.
}
}
}
}
}
}
post {
always {
node("${node1_host}") {
// 2개의 Stage 동작이 모두 완료되었을때 동작을 정의합니다. // e.g. Node1 + Node2 의 리포트 리소스를 병합하여 결과 리포트를 생성합니다.
}
}
}
}
script 구간 내에 빌드 명령어를 try-catch로 묶지 않으면 한 개의 테스트에서 오류 발생시 바로 post 구간을 수행합니다. 예를 들면 `gradle clean build test -–test TC_A --tests TC_B --tests TC_C` 로 빌드를 시작했을 때 TC_A에서 Exception이 발생했다면 TC_B와 TC_C는 수행하지 않게 됩니다.
Stages 하위 병렬 stage 작업이 모두 완료되면 최하위 post의 always에 작업을 정의합니다. 이해관계자에게 테스트 종료 이메일 알림, Webhook 작업을 정의할 수도 있고 리포트 모듈에 대한 정의도 할 수 있습니다. 저희는 gradle에서 생성해 주는 gradle report와 allure report를 같이 사용하고 있습니다. 병렬로 테스트가 진행되면 빌드 서버(Node) 별 gradle report는 각각 생성되는데 이는 병합이 불가능했으나 Allure report를 사용하면 병합이 가능합니다.
실제 테스트를 빌드 했을 땐 통합된 결과로 볼 수 있고 아래의 캡처에선 Node 별로 진행된 테스트 메소드 별 타임라인을 확인할 수 있습니다. 제일 늦게 완료된 노드가 15시간 45분이 소요되었네요. 필요시 빌드 서버(Node)를 더 늘리면 테스트 시간을 단축할 수 있습니다.
Q. 테스트 자동화로 일하면서 체감상 업무 효율이 얼마나 높아진 것 같나요?
스팸스나이퍼의 기준으로 초기부터 `환경설정`의 필터와 SMTP 기능 등을 목표로 구현했습니다. 관리자 페이지의 환경 설정엔 정말 많은 기능들이 모여 있거든요. 저희가 스팸스나이퍼 테스트 활동 시에도 가장 시간이 많이 걸리는 부분입니다. 환경설정에서의 자동화 테스트의 구현율은 약 47% 정도 되는 것 같습니다.
제품 특성상 Selenium과 Java 만으로 모든 기능에 대한 테스트케이스를 만들 순 없습니다. 하드웨어 종속 기능, 테스트 베드의 기타 제약사항도 존재하기 때문입니다. 기존의 같은 항목의 테스트에 소비되던 시간이 상당히 줄어들었고 현재도 커버리지를 계속 넓혀나가고 있습니다.
Q. 앞으로도 무언가를 계속 만들어 가실 건가요?
제가 해보고 싶은 것들을 지원해 주는 회사의 분위기에 많은 것 들을 시도해보고 삽질도 해가며 배울 수 있었습니다. 장기적으론 Docker와 Pipeline을 활용하여 조금 더 다이내믹한 테스트 환경 구축과 테스트 결과에 대한 로그를 ELK Stack으로 추가로 수집하여 기존의 리포트 모듈(Allure, Gralde)에서 보여주지 못한 다른 형태의 시각화 형태도 구상해 볼 예정입니다.