사내 E2E 테스트 도입 경험기 #2 - Playwright로 E2E 테스트 개발하기

띵지·2023년 4월 30일
4

E2E

목록 보기
2/3
post-thumbnail

무엇으로?

주제대상
IDEVSCode
언어TypeScript
실행 (빌드)Jenkins
테스트 로그 관리NHN Cloud Object Storage
테스트 결과 알림NHN Dooray Hook

E2E 테스트를 위해 고려되었던 것들은 위의 표 내용 정도로 간단하게 정리가 된다.

VSCode

나는 IntelliJ를 좋아하지만,
VSCode의 Extension에서 Playwright 확장 프로그램이 설치가 가능한 것을 보고 바로 VSCode로 넘어왔다. 테스트 실행은 GUI로 하는 것이 굉-장히 편리하기 때문에..!!

나중에 찾아보니 Jetbrains에서 테스트를 위한 IDE인 Aqua 베타 버전을 무료로 제공해주고 있다는 것을 알고 한번 써볼까 했는데.. 아쉽게도 현재는 셀레니움까지만 지원을 해주며 Playwright는 준비 중이라고 해서 VSCode로 시작을 했다.

TypeScript

이번 프로젝트 덕분에 TypeScript를 처음 접해보았다.
TypeScript 언어 자체의 장점도 많아서 한번쯤 도전해보고 싶어서 다른 언어와 크게 비교하면서 깊게 고민하지는 않았던 것 같다.

그리고.. 솔직하게 말하면, 아직 Playwright에 대한 커뮤니티가 크게 활성화되어 있지 않다.
그나마 활성화된 커뮤니티에서 TypeScript로 Playwright를 사용하여 개발한 사례가 비중이 높았기 때문에 좀더 편리한 개발을 위해 선택하였다. 주니어인 나에게는 같은 언어로 고민하는 사람들의 이야기를 보는 것이 훨씬 이해도 빠르고 효율도 좋을 것이라고 생각했다.

Jenkins

이번 E2E 프로젝트의 최종 목표는 QA 서버 배포 직후 자동으로 E2E 테스트까지 실행되는 것.
그래서 빌드 프로세스에 E2E 실행까지 포함되어야 했기 때문에 Jenkins 파이프라인 설계까지 작업이 필요했다.

테스트 로그 관리

젠킨스에서 테스트 실행을 목적으로 하는 빌드가 진행될 때, 미리 빌드된 도커 이미지를 실행함으로써 테스트가 돌아가게 된다. 테스트가 끝난 직후, 테스트 로그를 확인하기 위해 직접 도커 컨테이너에 접근하여 로그 파일을 찾아 들어가는 과정은 매우 불편하고 비효율적이라 테스트가 종료되면 로그를 한 곳으로 자동으로 업로드하여 관리를 해줄 필요가 있었다.
결론적으로 NHN Cloud에서 제공하는 Object Storage에 로그를 올려서 확인 가능하도록 하였다.

테스트 결과 알람

테스트 시작, 테스트 종료, 테스트 성공 및 실패 여부에 대해 알람을 실시간으로 받을 필요가 있었다. 알람을 받을 창구로 사내 메신저로 사용 중인 NHN Dooray의 Hook API를 사용하기로 하였다.


어떻게?

Page Object Model (POM)

MVC, Singleton 등의 패턴은 알고 있었지만
막상 "E2E 테스트를 위한 디자인 패턴..?" 을 생각하니 어떤 것을 참고해야 하나 고민이 컸다.

https://playwright.dev/docs/pom
Playwright 공식 문서에서는 POM 모델을 추천했고,
Celenium 등 E2E 테스트를 위해 다른 라이브러리를 사용하는 경우에도 일반적으로 POM 모델을 따르고 있었다.

간단하게는 테스트가 진행되는 Page와,
Page를 구성하는 Object로 나뉘어 설계를 한다고 생각하면 된다.

디렉토리 구조 잡기

POM 모델을 바탕으로 테스트 케이스를 추가하며 디렉토리 구조를 설계하기 시작했다.
그리고.. 정말 많이 고민하고 많이 갈아엎었다.

대략적인 구조는 아래와 같다.

SRC

src
├── admin
└── front
    ├── page-locators
    │   ├── mobile
    │   └── pc
    │       ├── login
    │       │   ├── ILoginLocators.ts
    │       │   ├── LoginPage.ts
    │       │   ├── skinA
    │       │   │   └── LoginLocators.ts
    │       │   └── skinB
    │       │       └── LoginLocators.ts
    │   	└── ...
    └── paths
        └── PagePaths.ts
Depth대상구분 이유
1환경환경에 따라 테스트가 아예 달라짐 (pc / mobile)
2서비스스킨에 따라 제공되는 서비스가 다르지 않음, 스킨이 달라도 동일한 Page를 공유함
3스킨위(2)와 동일

COMMON

Page, Object 세팅과 관련된 요소들을 src 하위로 위치시켰다면,
테스트 실행 전후, 테스트에서 전반적으로 활용되는 요소들은 common 하위로 위치시켰다.

☑️ common/data

common
├── data
│   └── ...

static 변수로 값을 저장하여 프로젝트 전반에서 활용되어야 하는 data 정보를 관리하는 곳이다.

테스트 실행 시, 테스트 정보에 대한 파라미터는 env 값으로 전달 받는데,
env 값을 static 변수로 저장하여 프로젝트 전반에서 활용할 수 있도록 해야했다.
지금은 이 역할을 common/data/** 에서 담당하고 있다.

// common/data/env/Domain
export class Domain {
    static hasDomain: boolean = process.env.DOMAIN !== undefined

    static DOMAIN: string = Domain.hasDomain ? process.env.DOMAIN! : ""
    static SUBDOMAIN: string = Domain.hasDomain ? (new URL(process.env.DOMAIN!).hostname!).split('.')[0] : ""
	// ... 생략
}

☑️ common/external

외부 서비스와의 연동이 필요한 경우가 많았다.
Dooray Hook 알람, Jenkins 빌드, OBS 업로드 등등..
외부 서비스와 api로 통신하는 곳은 external 폴더 하위로 분류하였다.

├── external
│   ├── dooray
│   ├── jenkins
│   └── obs

☑️ common/fixture

├── fixture
│   ├── Fixture.ts
│   └── (domain)
│       ├── Member.json
│       └── ...

테스트에 필요한 실제 데이터 값을 보관하고, 읽는 곳이다.
Fixture 클래스는 json 파일을 읽어 원하는 데이터를 array로 반환해주는 역할을 담당한다.

import { Domain } from '@common/data/env/Domain'
import * as fs from 'fs'

export class Fixture {
    static get(fileNm: string, key: string) {
        const jsonFile = fs.readFileSync(`common/fixture/${Domain.SUBDOMAIN}/${fileNm}.json`, 'utf8')
        const fixture = JSON.parse(jsonFile)[key]
        return fixture
    }
}

예를 들어 Memeber.json 파일 내에는 아래와 같이 데이터가 저장되어 있고,

{
	"testUser": {
        "id": "(id)",
        "name": "(name)",
        "pw": "(password)"
	},
    ...
}

실제로 값을 읽을 때는 아래와 같이 호출한다.

const fixture = Fixture.get("Member", "testUser")

☑️ common/hook

├── hook
│   ├── CustomReporter.ts
│   ├── afterEach
│   │   └── ...
│   └── beforeEach
│       └── ...

Playwright에서 기본적으로 제공해주는 Reporter 인터페이스를 implements하면
본인이 원하는 커스텀 레포트를 만들 수 있다.

export default class CustomReporter implements Reporter {
	// 전체 테스트 시작 전
  	onBegin(config: FullConfig, suite: Suite): Promise<void> { }
  
  	// 전체 테스트 종료 후
  	onEnd(result: FullResult): Promise<void> { }
 	 
  	// 개별 테스트 시작 전
  	onTestBegin(test: TestCase, result: TestResult): void { }
  
  	// 개별 테스트 종료 후
  	onTestEnd(test: TestCase, result: TestResult): void { }
}
    

각 케이스에 따라 적절하게 로그나 외부 api 호출 등을 추가하여 테스트 실행 결과 관리를 하였다.


☑️ common/log

├── log
│   └── Logging.ts

이번 프로젝트에서는 winston로 로그 관리를 하였다.
로그 작성에 대한 소스코드는 위 클래스로 관리하였다.


☑️ common/page

├── page
│   ├── Admin
│   │   └── AdminPageCommon.ts
│   ├── Front
│   │   ├── MobilePageCommon.ts
│   │   └── PcPageCommon.ts
│   └── PageCommon.ts

Page 관리에 대해 Common한 요소들만 모아놓은 곳이다.
Page 공통적으로 필요한, 특정 페이지를 여는 것을 Page.open()이라는 하나의 메서드로 통일시키기 위해 고민한 결과이다. 이건 별도의 포스팅으로 정리하는 것이 좋을 듯 싶다.

➡️ (포스팅 완성 후, 여기에 링크가 생성될 예정입니다.)


tests

tests
├── admin
│   └── Login.spec.ts
└── front
    ├── mobile
    │   └── Login.spec.ts
    └── pc
        └── Login.spec.ts

tests 내부도 src와 같이 depth를 환경 - 서비스 - 스킨으로 잡았다.
테스트 코드 작성은 보통 아래와 같은 구조를 따른다.

/**
 * @Title ( 테스트 제목 )
 * @Level ( 테스트 우선순위 )
 * @Given
 * ( 테스트 사전 조건)
 * @When
 * ( 테스트 수행 절차 )
 * @Then
 * ( 테스트 기대 결과 )
 */
test("[FRONT][PC] Login_0001: 로그인_성공", async ({ page }) => {
    // given
    let mainPage = new MainPage(page)
    let header = new Header(page)
    let loginPage = new LoginPage(page)
    const fixture = Fixture.get("Member", "commonUser")

    // when
    await mainPage.open()
    await page.click(header.locators.aLogin)
    await page.fill(loginPage.locators.inputId, fixture.id)
    await page.fill(loginPage.locators.inputPw, fixture.pw)
    await page.click(loginPage.locators.buttonLogin)
    await page.waitForNavigation({ waitUntil: "load", timeout: 10000 })

    // then
    const expected = Skin.SKIN == "(skinA)" ? "LOGOUT" : "로그아웃"
    expect(await page.textContent(header.locators.aLogout)).toBe(expected)
})

테스트 코드를 보면 아마 아래와 같이 구분한 이유가 더 명확하게 보이지 않을까 싶다.

├── login
   ├── ILoginLocators.ts
   ├── LoginPage.ts
   ├── skinA
   │   └── LoginLocators.ts
   └── skinB
       └── LoginLocators.ts

스킨별 Locators 클래스는 같은 Locators 인터페이스를 바라본다.

const { LoginLocators } = require ("./" + Skin.SKIN + "/LoginLocators")

export class LoginPage extends PcPageCommon {
    readonly locators: ILoginLocators
    
    constructor(page: Page) {
        super(page, PagePath.LOGIN_PAGE)
        this.locators = new LoginLocators()
    }
}

구체적인 Locators 값은 각 스킨별로 구분되어있기 때문에
테스트가 돌아가는 skin env 값에 따라 자동으로 적절한 skin의 Locators를 가져오게 된다.


테스트 개발 완료

위 구조를 바탕으로 전반적인 테스트 케이스 개발이 가능해졌다.
내가 고민해서 만든 구조가 정답인지는 모르겠지만.. 여러번 갈아엎는 과정에서 비효율적인 구조를 정말 많이 버렸기 때문에 평균 이상의 역할은 하고 있다고 생각한다.

테스트 코드 작성이 완료되었다면, 이제는 실제 배포 프로세스에 E2E 테스트를 녹일 차례다.
이건 다음 포스팅에서 이어서 진행해야겠다 !

profile
1년차 주니어 백엔드 개발자, 기록으로 완성하는 나

0개의 댓글