Skip to main content

자동화 테스트

테스트 자동화는 애플리케이션 코드가 의도한 대로 동작하는지 검증하는 효율적인 방법이다. Electron은 자체적인 테스트 솔루션을 적극적으로 유지하지 않지만, 이 가이드에서는 Electron 앱에서 종단 간(end-to-end) 자동화 테스트를 실행할 수 있는 몇 가지 방법을 소개한다.

WebDriver 인터페이스 사용하기

ChromeDriver - WebDriver for Chrome에서 발췌:

WebDriver는 다양한 브라우저에서 웹 애플리케이션을 자동으로 테스트하기 위한 오픈소스 도구다. 웹 페이지로 이동하거나 사용자 입력을 처리하고, 자바스크립트를 실행하는 등의 기능을 제공한다. ChromeDriver는 Chromium을 위한 WebDriver의 와이어 프로토콜을 구현한 독립 실행형 서버다. Chromium과 WebDriver 팀의 구성원들이 개발하고 있다.

WebDriver를 사용해 테스트를 설정하는 몇 가지 방법이 있다.

WebdriverIO 활용하기

WebdriverIO (WDIO)는 WebDriver를 사용해 테스트를 수행할 수 있는 Node.js 패키지를 제공하는 테스트 자동화 프레임워크다. 이 프레임워크는 다양한 플러그인(예: 리포터와 서비스)도 포함하고 있어 테스트 환경을 구성하는 데 도움을 준다.

이미 WebdriverIO 설정이 있다면, 의존성을 업데이트하고 기존 설정을 공식 문서에 명시된 내용과 비교해 검증하는 것이 좋다.

테스트 러너 설치하기

프로젝트에 WebdriverIO를 아직 사용하지 않는다면, 프로젝트 루트 디렉토리에서 스타터 툴킷을 실행해 추가할 수 있다:

npm init wdio@latest ./

이 명령어는 설정 마법사를 시작해 적절한 설정을 구성하고, 필요한 모든 패키지를 설치하며, wdio.conf.js 설정 파일을 생성한다. 설정 과정 중 _"어떤 타입의 테스트를 수행하고 싶은가요?"_라는 질문이 나오면 _"Electron 애플리케이션의 데스크톱 테스트"_를 선택해야 한다.

Electron 앱에 WDIO 연결하기

설정 마법사를 실행한 후, wdio.conf.js 파일에는 대략 다음과 같은 내용이 포함된다:

wdio.conf.js
export const config = {
// ...
services: ['electron'],
capabilities: [{
browserName: 'electron',
'wdio:electronServiceOptions': {
// WebdriverIO는 Electron Forge나 electron-builder를 사용할 경우
// 자동으로 번들링된 애플리케이션을 찾을 수 있다. 그렇지 않다면
// 여기에서 직접 정의할 수 있다. 예를 들어:
// appBinaryPath: './path/to/bundled/application.exe',
appArgs: ['foo', 'bar=baz']
}
}]
// ...
}

테스트 작성하기

WebdriverIO API를 사용해 화면의 엘리먼트와 상호작용할 수 있다. 이 프레임워크는 애플리케이션 상태를 쉽게 검증할 수 있는 커스텀 "matchers"를 제공한다. 예를 들어:

import { browser, $, expect } from '@wdio/globals'

describe('keyboard input', () => {
it('should detect keyboard input', async () => {
await browser.keys(['y', 'o'])
await expect($('keypress-count')).toHaveText('YO')
})
})

또한 WebdriverIO를 통해 Electron API에 접근해 애플리케이션의 정적 정보를 가져올 수 있다:

import { browser, $, expect } from '@wdio/globals'

describe('when the make smaller button is clicked', () => {
it('should decrease the window height and width by 10 pixels', async () => {
const boundsBefore = await browser.electron.browserWindow('getBounds')
expect(boundsBefore.width).toEqual(210)
expect(boundsBefore.height).toEqual(310)

await $('.make-smaller').click()
const boundsAfter = await browser.electron.browserWindow('getBounds')
expect(boundsAfter.width).toEqual(200)
expect(boundsAfter.height).toEqual(300)
})
})

다른 Electron 프로세스 정보를 가져오는 것도 가능하다:

import fs from 'node:fs'
import path from 'node:path'
import { browser, expect } from '@wdio/globals'

const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), { encoding: 'utf-8' }))
const { name, version } = packageJson

describe('electron APIs', () => {
it('should retrieve app metadata through the electron API', async () => {
const appName = await browser.electron.app('getName')
expect(appName).toEqual(name)
const appVersion = await browser.electron.app('getVersion')
expect(appVersion).toEqual(version)
})

it('should pass args through to the launched application', async () => {
// 커스텀 인자는 WDIO가 시작되기 전에 설정해야 하므로 wdio.conf.js 파일에서 설정됨
const argv = await browser.electron.mainProcess('argv')
expect(argv).toContain('--foo')
expect(argv).toContain('--bar=baz')
})
})

테스트를 실행하려면 다음 명령어를 사용한다:

$ npx wdio run wdio.conf.js

WebdriverIO는 애플리케이션을 자동으로 실행하고 종료하는 작업을 지원한다.

추가 문서

Mocking Electron API와 다른 유용한 리소스에 대한 더 많은 문서는 공식 WebdriverIO 문서에서 확인할 수 있다.

Selenium 활용하기

Selenium은 웹 자동화 프레임워크로, 다양한 언어에서 WebDriver API에 접근할 수 있는 바인딩을 제공한다. Node.js용 바인딩은 NPM의 selenium-webdriver 패키지에서 이용할 수 있다.

ChromeDriver 서버 실행하기

Electron과 Selenium을 함께 사용하려면 electron-chromedriver 바이너리를 다운로드하고 실행해야 한다.

npm install --save-dev electron-chromedriver
./node_modules/.bin/chromedriver
Starting ChromeDriver (v2.10.291558) on port 9515
Only local connections are allowed.

이때 사용된 포트 번호 9515는 나중에 사용되므로 기억해 두자.

Selenium과 ChromeDriver 연결하기

먼저 프로젝트에 Selenium을 설치한다:

npm install --save-dev selenium-webdriver

selenium-webdriver를 Electron과 함께 사용하는 방법은 일반 웹사이트와 동일하다. 단, ChromeDriver에 연결하는 방법과 Electron 앱의 바이너리 위치를 직접 지정해야 한다:

test.js
const webdriver = require('selenium-webdriver')
const driver = new webdriver.Builder()
// "9515"는 ChromeDriver가 열어 놓은 포트다.
.usingServer('http://localhost:9515')
.withCapabilities({
'goog:chromeOptions': {
// 여기에 Electron 바이너리의 경로를 입력한다.
binary: '/Path-to-Your-App.app/Contents/MacOS/Electron'
}
})
.forBrowser('chrome') // 참고: selenium-webdriver <= 3.6.0 버전에서는 .forBrowser('electron')을 사용한다.
.build()
driver.get('https://www.google.com')
driver.findElement(webdriver.By.name('q')).sendKeys('webdriver')
driver.findElement(webdriver.By.name('btnG')).click()
driver.wait(() => {
return driver.getTitle().then((title) => {
return title === 'webdriver - Google Search'
})
}, 1000)
driver.quit()

Playwright 사용하기

Microsoft Playwright는 브라우저별 원격 디버깅 프로토콜을 사용해 구축된 엔드투엔드 테스트 프레임워크다. Puppeteer의 헤드리스 Node.js API와 유사하지만, 엔드투엔드 테스트에 더 초점을 맞춘다. Playwright는 Chrome DevTools Protocol (CDP)에 대한 Electron의 지원을 통해 실험적인 Electron 지원도 제공한다.

의존성 설치

원하는 Node.js 패키지 매니저를 통해 Playwright를 설치한다. Playwright는 엔드 투 엔드 테스트를 위해 설계된 자체 테스트 러너를 제공한다:

npm install --save-dev @playwright/test
의존성 관련 주의사항

이 튜토리얼은 @playwright/test@1.41.1 버전을 기준으로 작성되었다. 아래 코드에 영향을 미칠 수 있는 변경 사항을 확인하려면 Playwright 릴리스 페이지를 참고한다.

테스트 작성하기

Playwright는 _electron.launch API를 통해 개발 모드에서 앱을 실행한다. 이 API가 Electron 앱을 가리키도록 하려면 메인 프로세스의 진입점(여기서는 main.js) 경로를 전달하면 된다.

const { test, _electron: electron } = require('@playwright/test')

test('launch app', async () => {
const electronApp = await electron.launch({ args: ['main.js'] })
// 앱 종료
await electronApp.close()
})

이후에는 Playwright의 ElectronApp 클래스 인스턴스에 접근할 수 있다. 이 클래스는 메인 프로세스 모듈에 접근할 수 있는 강력한 기능을 제공한다. 예를 들어:

const { test, _electron: electron } = require('@playwright/test')

test('get isPackaged', async () => {
const electronApp = await electron.launch({ args: ['main.js'] })
const isPackaged = await electronApp.evaluate(async ({ app }) => {
// 이 코드는 Electron의 메인 프로세스에서 실행되며, 여기서의 매개변수는 항상
// 메인 앱 스크립트에서 require('electron')의 결과다.
return app.isPackaged
})
console.log(isPackaged) // false (개발 모드이기 때문)
// 앱 종료
await electronApp.close()
})

또한 이 클래스는 Electron BrowserWindow 인스턴스에서 개별 Page 객체를 생성할 수 있다. 예를 들어, 첫 번째 BrowserWindow를 가져와 스크린샷을 저장하려면:

const { test, _electron: electron } = require('@playwright/test')

test('save screenshot', async () => {
const electronApp = await electron.launch({ args: ['main.js'] })
const window = await electronApp.firstWindow()
await window.screenshot({ path: 'intro.png' })
// 앱 종료
await electronApp.close()
})

이 모든 것을 Playwright 테스트 러너를 사용해 결합해 보자. example.spec.js 테스트 파일을 만들고 단일 테스트와 단언을 추가한다:

example.spec.js
const { test, expect, _electron: electron } = require('@playwright/test')

test('example test', async () => {
const electronApp = await electron.launch({ args: ['.'] })
const isPackaged = await electronApp.evaluate(async ({ app }) => {
// 이 코드는 Electron의 메인 프로세스에서 실행되며, 여기서의 매개변수는 항상
// 메인 앱 스크립트에서 require('electron')의 결과다.
return app.isPackaged
})

expect(isPackaged).toBe(false)

// 첫 번째 BrowserWindow가 열릴 때까지 기다린 후
// 해당 Page 객체를 반환
const window = await electronApp.firstWindow()
await window.screenshot({ path: 'intro.png' })

// 앱 종료
await electronApp.close()
})

그런 다음 npx playwright test를 실행해 Playwright Test를 실행한다. 콘솔에서 테스트가 통과되는 것을 확인할 수 있으며, 파일 시스템에 intro.png 스크린샷이 생성된다.

☁  $ npx playwright test

Running 1 test using 1 worker

✓ example.spec.js:4:1 › example test (1s)
info

Playwright Test는 .*(test|spec)\.(js|ts|mjs) 정규식과 일치하는 파일을 자동으로 실행한다. 이 매칭은 Playwright Test 설정 옵션에서 커스터마이즈할 수 있다. 또한 TypeScript도 기본적으로 지원한다.

추가 자료

Playwright의 ElectronElectronApplication 클래스 API에 대한 전체 문서를 확인해 보자.

커스텀 테스트 드라이버 사용하기

Node.js의 내장 IPC-over-STDIO를 활용해 직접 테스트 드라이버를 작성할 수도 있다. 커스텀 테스트 드라이버는 추가적인 앱 코드를 작성해야 하지만, 오버헤드가 적고 테스트 스위트에 커스텀 메서드를 노출할 수 있다는 장점이 있다.

커스텀 드라이버를 만들기 위해 Node.js의 child_process API를 사용한다. 테스트 스위트는 Electron 프로세스를 실행한 후 간단한 메시징 프로토콜을 설정한다:

testDriver.js
const childProcess = require('node:child_process')
const electronPath = require('electron')

// 프로세스 실행
const env = { /* ... */ }
const stdio = ['inherit', 'inherit', 'inherit', 'ipc']
const appProcess = childProcess.spawn(electronPath, ['./app'], { stdio, env })

// 앱에서 보낸 IPC 메시지 수신
appProcess.on('message', (msg) => {
// ...
})

// 앱에 IPC 메시지 전송
appProcess.send({ my: 'message' })

Electron 앱 내부에서는 Node.js process API를 사용해 메시지를 수신하고 응답을 보낼 수 있다:

main.js
// 테스트 스위트에서 보낸 메시지 수신
process.on('message', (msg) => {
// ...
})

// 테스트 스위트에 메시지 전송
process.send({ my: 'message' })

이제 appProcess 객체를 사용해 테스트 스위트와 Electron 앱 간에 통신할 수 있다.

편의를 위해 appProcess를 더 높은 수준의 함수를 제공하는 드라이버 객체로 감싸는 것이 좋다. 이를 위해 TestDriver 클래스를 만들어 보자:

testDriver.js
class TestDriver {
constructor ({ path, args, env }) {
this.rpcCalls = []

// 자식 프로세스 시작
env.APP_TEST_DRIVER = 1 // 앱이 메시지를 수신해야 함을 알림
this.process = childProcess.spawn(path, args, { stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })

// RPC 응답 처리
this.process.on('message', (message) => {
// 핸들러 추출
const rpcCall = this.rpcCalls[message.msgId]
if (!rpcCall) return
this.rpcCalls[message.msgId] = null
// reject/resolve 처리
if (message.reject) rpcCall.reject(message.reject)
else rpcCall.resolve(message.resolve)
})

// 준비 완료 대기
this.isReady = this.rpc('isReady').catch((err) => {
console.error('애플리케이션 시작 실패', err)
this.stop()
process.exit(1)
})
}

// 간단한 RPC 호출
// 사용법: driver.rpc('method', 1, 2, 3).then(...)
async rpc (cmd, ...args) {
// RPC 요청 전송
const msgId = this.rpcCalls.length
this.process.send({ msgId, cmd, args })
return new Promise((resolve, reject) => this.rpcCalls.push({ resolve, reject }))
}

stop () {
this.process.kill()
}
}

module.exports = { TestDriver }

앱 코드에서는 RPC 호출을 수신하는 간단한 핸들러를 작성할 수 있다:

main.js
const METHODS = {
isReady () {
// 필요한 설정 수행
return true
}
// RPC 호출 가능한 메서드 정의
}

const onMessage = async ({ msgId, cmd, args }) => {
let method = METHODS[cmd]
if (!method) method = () => new Error('잘못된 메서드: ' + cmd)
try {
const resolve = await method(...args)
process.send({ msgId, resolve })
} catch (err) {
const reject = {
message: err.message,
stack: err.stack,
name: err.name
}
process.send({ msgId, reject })
}
}

if (process.env.APP_TEST_DRIVER) {
process.on('message', onMessage)
}

그런 다음 테스트 스위트에서 원하는 테스트 자동화 프레임워크와 함께 TestDriver 클래스를 사용할 수 있다. 다음 예제는 ava를 사용하지만, Jest나 Mocha와 같은 다른 인기 있는 선택지도 가능하다:

test.js
const test = require('ava')
const electronPath = require('electron')
const { TestDriver } = require('./testDriver')

const app = new TestDriver({
path: electronPath,
args: ['./app'],
env: {
NODE_ENV: 'test'
}
})
test.before(async t => {
await app.isReady
})
test.after.always('cleanup', async t => {
await app.stop()
})