Post

데브코스 TIL - E2E 테스트 자동화

E2E 테스트 자동화

✅실습 내용

Headless browser를 이용한 테스트

테스트는 빈번하게 일어나기 때문에 이 때마다 사용자 인터페이스 요소들을 렌더링하는 것은 불필요한 오버헤드가 큼

selenium gird를 이용한 테스트 환경 구축

개발 단계에서 종단간 테스트의 결과를 눈과 손으로 확인하는데 활용

✅Headless browser?

브라우저의 동작을 포함하는 종단간 테스트의 자동화에 사용하는 GUI가 없는 브라우저

https://broswerstack.com/guide/what-is-headless-browser-testing

실습

  • selenium IDE로 만들었던 로그인 테스트 로직 수정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Generated by Selenium IDE
import pytest
import time
import json
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities

class TestLogintest():
  def setup_method(self, method):
    self.driver = webdriver.Chrome()
    self.vars = {}
  
  def teardown_method(self, method):
    self.driver.quit()
  
  def test_logintest(self):
    self.driver.get("http://localhost:3000/")
    self.driver.set_window_size(1078, 1020)
    self.driver.find_element(By.LINK_TEXT, "무료로 시작하기").click()
    self.driver.find_element(By.ID, "emailInput").click()
    self.driver.find_element(By.ID, "emailInput").send_keys("test@example.com")
    self.driver.find_element(By.ID, "passwordInput").click()
    self.driver.find_element(By.ID, "passwordInput").send_keys("1234")
    self.driver.find_element(By.ID, "login-button").click()
  • headless browser에 맞도록 수정한 파일
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Generated by Selenium IDE
import pytest
import time
import json
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities

class TestLogintest():
  def setup_method(self, method):
    # headless browser 테스트 코드 추가
    options = webdriver.ChromeOptions()
    options.add_argument('--headless=new')
    self.driver = webdriver.Chrome(options=options)
    self.vars = {}
  
  def teardown_method(self, method):
    self.driver.quit()
  
  def test_logintest(self):
    self.driver.get("http://localhost:3000/")
    self.driver.set_window_size(1078, 1020)
    self.driver.find_element(By.LINK_TEXT, "무료로 시작하기").click()
    self.driver.find_element(By.ID, "emailInput").click()
    self.driver.find_element(By.ID, "emailInput").send_keys("test@example.com")
    self.driver.find_element(By.ID, "passwordInput").click()
    self.driver.find_element(By.ID, "passwordInput").send_keys("1234")
    self.driver.find_element(By.ID, "login-button").click()
  • 테스트 실행
    1
    
    python -m pytest
    

크롬창이 오픈되지 않고, 테스트가 진행됨
크롬창에 오픈하고 테스트하는 것보다 조금 빨리 테스트가 완료됨

셀레니움 그리드 활용 (standalone-chrome)

1. Docker-compose를 사용하여 테스트 환경 구축 (셀레니움)

기존에 만들어두었던 root 경로의 docker-compose.yaml파일에 코드 추가

1
2
3
4
5
6
7
8
(...)

  selenium:
    image: selenium/standalone-chrome
    ports:
      - 4444:4444

(...)

이 때, 셀레니움에 의해서 테스트 접속할 때에는 다음과 같아야 함 (frontend, backend와 같은 네트워크에 존재하기 때문에)

  • http://localhost:3031http://backend:3031
  • http://localhost:3030http://frontend:3031

cross-site request에 의해 전달되는 cookie는 SameSite:'none',Secure:true인 경우에만 받아들여짐. => 같은 네트워크에 웹 서버를 reverse proxy로 함께 구동하여 single origin이 되도록 설정 SameSite:'lax', Secure:false

1
2
3
4
5
6
7
8
9
10
11
12
13
	res.cookie('access-token', accessToken, {
		httpOnly: true,
		...(process.env.NODE_ENV === 'development'
			? {
					sameSite: 'lax',
					secure: false,
			  }
			: {
					sameSite: 'none',
					secure: true,
			  }),
		maxAge: 1000 * 60 * 60 * 24 * 14, // 14일
	});

2. nginx reverse proxy 설정

notes.conf

Docker 컨테이너 안의 /etc/nginx/conf.d/default.conf을 덮어 쓰도록 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server {
  location / {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_pass http://frontend:3000/;
  }

  location /api/ {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_pass http://backend:3031/;
  }
}

Dockerfile

1
2
3
FROM nginx:latest

COPY notes.cof /etc/nginx/conf.d/default.conf

3. docker-compose.yaml에 네트워크 구성 코드 추가 및 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
version: '3.8'

services:
  notes:
    build: ./notes-web
    ports:
      - 4000:80
    networks:
      - notes
  frontend:
    image: 'hyeem66/prgms-fe:latest'
    environment:
      REACT_APP_API_BASE_URL: http://notes/api
    ports:
      - 3000:3000
    networks:
      - notes
  backend:
    image: 'hyeem66/prgms-test:latest'
    environment:
      NODE_ENV: development
      DB_HOST: db
      DB_PORT: 3306
    ports:
      - 3031:3031
    networks:
      - notes
  db:
    image: mariadb:11.2.2
    environment:
      - MARIADB_ROOT_PASSWORD:root
    # 로컬 컴퓨터 콘솔에서 접근하는 경우 - localhost:3032
    # docker compose에 의하여 같은 네트워크에서 실행하는 컨테이너에서 접근 - db:3306
    ports:
      - 80:3306
    networks:
      - notes
    # host:mysql경로 (volume 공유)
    volumes:
      - ./db:/var/lib/mysql
  selenium:
    image: selenium/standalone-chrome
    ports:
      - 4444:4444
    networks:
      - notes

networks:
  notes: {}

도커 실행

1
2
3
4
5
6
7
8
docker compose up -d

[+] Running 5/5
 ✔ Container prgms-notes-frontend-1  Started   
 ✔ Container prgms-notes-notes-1     Running   
 ✔ Container prgms-notes-db-1        Started   
 ✔ Container prgms-notes-backend-1   Running   
 ✔ Container prgms-notes-selenium-1  Started

4. 테스트 코드 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# Generated by Selenium IDE
import pytest
import time
import json
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities

# BASE URL = "http://localhost:30030" 
BASE URL = "http://notes" 

class TestNotesView():
  def setup_method(self, method):
    # headless browser 설정
    options = webdriver.ChromeOptions()
    self.driver = webdriver.Remote(
      command_executor = 'http://localhost:4444/wd/hub',
      options=options
    )
    self.vars = {}
    self.login()

  
  def teardown_method(self, method):
    self.logout()
    self.driver.quit()
  

  def test_notesview(self):
    time.sleep(1)
    self.driver.get(BASE URL + "/notes")
    self.driver.set_window_size(1078, 1020)
    self.driver.impicitly_wait(10)

    # 로그인한 사용자의 ID 확인
    assert self.driver.find_element(
      By.ID, "current-user"
    ).text == "test@example.con"

    # 노트 목록의 올바른 순서와 내용 확인
    notes_list = self.driver.find_element(By.ID, "note-list")
    assert notes_list.find_element(
      By.CSS_SELECTOR, "li:nth-child(1) span"
    ).text == "Test (2)"
    assert notes_list.find_element(
      By.CSS_SELECTOR, "li:nth-child(2) span"
    ).text == "Test (1)"
    # self.driver.find_element(By.XPATH, "//div[@id=\'root\']/div/div/aside/div[3]/ul/li[last()]/a/span").click()


    # 목록의 맨 아래 노트 제목 클릭
    notes_list.find_element(By.XPATH, "ul[@id=\'note-list\']/li[last()]/a/span").click()
    self.driver.impicitly_wait(2)
    assert self.driver.find_element(
      By.CSS_SELECTOR, "article header textarea"
    ).text == "Test (1)"
    assert self.driver.find_element(
      By.CSS_SELECTOR, "article main div div"
    ).get_attribute('innerHTML') == "<p>This note is for testing.</p>" \
      + "<p>Note number: 1</p>"


  def login(self):
    self.driver.get(BASE URL + "/")
    self.driver.impicitly_wait(10)
    self.driver.find_element(By.LINK_TEXT, "무료로 시작하기").click()
    self.driver.find_element(By.ID, "emailInput").click()
    self.driver.find_element(By.ID, "emailInput").send_keys("test@example.com")
    self.driver.find_element(By.ID, "passwordInput").click()
    self.driver.find_element(By.ID, "passwordInput").send_keys("1234")
    self.driver.find_element(By.ID, "login-button").click()
    
  def logout(self):
    self.driver.find_element(By.ID, "logout-button").click()
This post is licensed under CC BY 4.0 by the author.