Post

FEํ…Œ์ŠคํŠธ(3) - React Testing Library

๐Ÿ“Œ์‹œ์ž‘ํ•˜๋ฉฐ

์ด๋ฒˆ์—” vitest์™€ ํ•จ๊ป˜ ์‚ฌ์šฉ๋˜์–ด ์‚ฌ์šฉ์ž ๊ด€์ ์—์„œ ํ…Œ์ŠคํŠธ ํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋„์™€์ฃผ๋Š” React Testing Library์— ๋Œ€ํ•ด ์•Œ์•„๋ณด๊ณ ์ž ํ•œ๋‹ค.

React Testing Library๋Š” ์‹ค์ œ ์‚ฌ์šฉ์ž๊ฐ€ UI๋ฅผ ์–ด๋–ป๊ฒŒ ์‚ฌ์šฉํ•˜๋Š”์ง€ ์ดˆ์ ์„ ๋งž์ถฐ ํ…Œ์ŠคํŠธํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•˜๋ฉฐ, ์ด๋ฅผ ๊ตฌํ˜„ํ•˜๊ธฐ ์œ„ํ•œ API๋ฅผ ์‚ดํŽด๋ณด์ž.

โœ…DOM ์š”์†Œ ์„ ํƒ (get~)

DOM ์š”์†Œ๋ฅผ ์„ ํƒํ•˜๋Š” ๋ฐฉ์‹ (Queries)๋Š” ์„ธ ๊ฐ€์ง€ ๋ฐฉ์‹์ด ์žˆ๋‹ค.

์ข…๋ฅ˜์„ค๋ช…
getBy์ผ์น˜ํ•˜๋Š” ์š”์†Œ๋ฅผ ์ฆ‰์‹œ ๋ฐ˜ํ™˜, ์š”์†Œ๊ฐ€ ์—†์œผ๋ฉด ์—๋Ÿฌ ๋ฐœ์ƒ
queryBy์ผ์น˜ํ•˜๋Š” ์š”์†Œ ๋ฐ˜ํ™˜, ์—†์œผ๋ฉด null ๋ฐ˜ํ™˜
findBy๋น„๋™๊ธฐ์ ์œผ๋กœ ์š”์†Œ ์ฐพ์Œ, ์š”์†Œ๊ฐ€ ์—†์„ ๋• Promise ๊ฑฐ๋ถ€, ์‹œ๊ฐ„์ด ์ง€๋‚˜๋ฉด ์žฌ์‹œ๋„

โžก๏ธgetBy

์ข…๋ฅ˜์„ค๋ช…
getByRole์ ‘๊ทผ์„ฑ ํŠธ๋ฆฌ ์ด์šฉ. ๊ฐ€์žฅ ๊ถŒ์žฅ๋จ
getByLabelText์ฃผ๋กœ ํผ ํ•„๋“œ์˜ ๋ผ๋ฒจ ์ด์šฉํ•ด ๊ฒ€์ƒ‰
getByPlaceholderTextํ”Œ๋ ˆ์ด์Šค ํ™€๋”๋ฅผ ์ด์šฉํ•ด ๊ฒ€์ƒ‰
getByTextํ…์ŠคํŠธ๋ฅผ ์ด์šฉํ•ด ์š”์†Œ ๊ฒ€์ƒ‰
getByAltTextalt ์†์„ฑ์„ ๊ฐ€์ง„ ์š”์†Œ๋ฅผ ๊ฒ€์ƒ‰
getByTitletitle ์†์„ฑ์„ ๊ฐ€์ง„ ์š”์†Œ๋ฅผ ๊ฒ€์ƒ‰
getByTestIdํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•ด element์— ์ด๋ฆ„์„ ๋ถ™์ž„. data-testid๋ฅผ ์‚ฌ์šฉํ•ด ์ •์˜
getByDisplayValueํผ์—์„œ ํ˜„์žฌ ์ž…๋ ฅ๋œ ๊ฐ’ ๊ธฐ๋ฐ˜์œผ๋กœ ์š”์†Œ ์„ ํƒ

โžก๏ธscreen

์ด์ œ ์œ„์—์„œ ์‚ดํŽด๋ณธ ๋‚ด์šฉ์„ ํ™œ์šฉํ•ด ์š”์†Œ๋ฅผ ์ฐพ์•„๋ณด์ž. DOM ์š”์†Œ๋ฅผ ์ฐพ์„ ๋•Œ๋Š” screen ํ‚ค์›Œ๋“œ๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค. screen์€ document.body์— ๋ฐ”์ธ๋”ฉ๋˜์–ด ์žˆ์–ด, screen.getBy~ findBy~ ๋“ฑ์„ ์‚ฌ์šฉํ•ด DOM์—์„œ ์š”์†Œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์žˆ๋‹ค.

1
2
3
4
5
6
7
8
9
10
11
import { screen, render } from "@testing-library/react"
import { describe, it, expect } from "vitest"
import Example from "./Example"

describe("Example ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ", () => {
  it("Example ์ปดํฌ๋„ŒํŠธ ๋‚ด ์š”์†Œ๋ฅผ ๊ฒ€์ƒ‰ํ•œ๋‹ค.", () => {
    render(<Example />)
    const element = screen.getByText("๋‚ด๊ฐ€ ์ฐพ๋Š” ์š”์†Œ")
    expect(element).toBeInTheDocument()
  })
})

โœ…์ด๋ฒคํŠธ ์‹œ๋ฎฌ๋ ˆ์ด์…˜

์œ„์—์„œ DOM์š”์†Œ๋ฅผ ์ฐพ๊ณ  ๋ Œ๋”๋ง ํ–ˆ์œผ๋‹ˆ ์ด๋ฒˆ์—” ์‚ฌ์šฉ์ž ์ด๋ฒคํŠธ๋ฅผ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ํ•ด๋ณด์ž.

โžก๏ธfireEvent

๊ฐ„๋‹จํ•œ ์‚ฌ์šฉ์ž ์ด๋ฒคํŠธ (click, change, keydown ๋“ฑ)๋Š” fireEvent๋ฅผ ์ด์šฉํ•ด ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ํ•  ์ˆ˜ ์žˆ๋‹ค.

์•„๋ž˜ ์˜ˆ์ œ๋Š” ๋ฒ„ํŠผ ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋ Œ๋”๋งํ•˜๊ณ  ์‚ฌ์šฉ์ž๊ฐ€ ํด๋ฆญํ•˜๋Š” ์ƒํ™ฉ์„ ํ…Œ์ŠคํŠธํ•œ๋‹ค.

โ€ป๊ณต์‹ ๋ฌธ์„œ์—์„œ๋Š” fireEvent ๋Œ€์‹  userEvent๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•˜๋ฏ€๋กœ, ์•„๋ž˜์— ์ž‘์„ฑํ•œ userEvent๋ฅผ ์ฃผ๋กœ ์‚ฌ์šฉํ•˜์ž.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { render, fireEvent, screen } from "@testing-library/react"
import { vi } from "vitest"

const Button = ({ onClick }) => <button onClick={onClick}>ํด๋ฆญํ•˜์„ธ์š”</button>

it("๋ฒ„ํŠผ ํด๋ฆญ ์‹œ onClick ํ•จ์ˆ˜๊ฐ€ ํ˜ธ์ถœ๋œ๋‹ค", () => {
  // vitest๋ฅผ ์ด์šฉํ•ด handleClick์ด๋ž€ ํ•จ์ˆ˜๋ฅผ ๋ชจํ‚นํ•œ๋‹ค.
  const handleClick = vi.fn()

  // ๋ฒ„ํŠผ ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋ Œ๋”๋ง ํ•œ๋‹ค.
  render(<Button onClick={handleClick} />)

  //text๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์š”์†Œ๋ฅผ ์ฐพ๊ณ , ํด๋ฆญ ์ด๋ฒคํŠธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚จ๋‹ค.
  fireEvent.click(screen.getByText("ํด๋ฆญํ•˜์„ธ์š”"))

  // handleClick ํ•จ์ˆ˜๊ฐ€ 1๋ฒˆ ํ˜ธ์ถœ ๋˜๋Š” ๊ฒƒ์„ ๊ฒ€์ฆํ•œ๋‹ค.
  expect(handleClick).toHaveBeenCalledTimes(1)
})

fireEvent๋กœ ๊ฒ€์ฆํ•  ์ˆ˜ ์žˆ๋Š” ์ด๋ฒคํŠธ๋Š” ์•„๋ž˜์™€ ๊ฐ™๋‹ค.

์ด๋ฒคํŠธ ์ด๋ฆ„์„ค๋ช…์˜ˆ์‹œ ์ฝ”๋“œ
clickํด๋ฆญ ์ด๋ฒคํŠธfireEvent.click(screen.getByText("ํด๋ฆญ"))
change๊ฐ’์ด ๋ณ€๊ฒฝ๋  ๋•Œ (์ž…๋ ฅ, ์„ ํƒ)fireEvent.change(screen.getByLabel("์ด๋ฆ„"), { target: { value: '์‹ ์งฑ๊ตฌ' } })
keydownํ‚ค๊ฐ€ ๋ˆŒ๋ ธ์„ ๋•ŒfireEvent.keyDown(screen.getByText("ํ‚ค๋ณด๋“œ ๋ˆ„๋ฆ„"), { key: 'Enter', code: 'Enter' })
keyupํ‚ค๊ฐ€ ๋–ผ์–ด์กŒ์„ ๋•ŒfireEvent.keyUp(screen.getByText("ํ‚ค๋ณด๋“œ ๋—Œ"), { key: 'Enter', code: 'Enter' })
keypressํ‚ค๊ฐ€ ๋ˆŒ๋ ค์กŒ์„ ๋•ŒfireEvent.keyPress(screen.getByText("ํ‚ค๋ณด๋“œ ๋ˆŒ๋ ค์ง"), { key: 'a', code: 'KeyA' })
focusํฌ์ปค์Šค๊ฐ€ ๊ฐ”์„ ๋•ŒfireEvent.focus(screen.getByText("ํฌ์ปค์Šค"))
blurํฌ์ปค์Šค๊ฐ€ ์‚ฌ๋ผ์งˆ ๋•ŒfireEvent.blur(screen.getByText("๋ธ”๋Ÿฌ"))
submitํผ ์ œ์ถœ ์ด๋ฒคํŠธfireEvent.submit(screen.getByText("ํผ์„ ์ œ์ถœํ•จ"))
mouseOver๋งˆ์šฐ์Šค๋ฅผ ์š”์†Œ ์œ„๋กœ ์˜ฌ๋ ธ์„ ๋•ŒfireEvent.mouseOver(screen.getByText("๋งˆ์šฐ์Šค ์˜ค๋ฒ„"))
mouseOut๋งˆ์šฐ์Šค๋ฅผ ์š”์†Œ ๋ฐ–์œผ๋กœ ๋บ์„ ๋•ŒfireEvent.mouseOut(screen.getByText("๋งˆ์šฐ์Šค ์•„์›ƒ"))
drag๋“œ๋ž˜๊ทธํ•  ๋•ŒfireEvent.drag(screen.getByText("๋“œ๋ž˜๊ทธ ์ด๋ฒคํŠธ"))
dragStart๋“œ๋ž˜๊ทธ๋ฅผ ์‹œ์ž‘ํ•  ๋•ŒfireEvent.dragStart(screen.getByText("๋“œ๋ž˜๊ทธ ์‹œ์ž‘"))
dragEnd๋“œ๋ž˜๊ทธ๋ฅผ ๋๋‚ผ ๋•ŒfireEvent.dragEnd(screen.getByText("๋“œ๋ž˜๊ทธ ๋"))
drop๋“œ๋กญํ•  ๋•ŒfireEvent.drop(screen.getByText("๋“œ๋กญ ์˜์—ญ"), { dataTransfer: { files: [new File([], 'a.txt')] } })
input์ž…๋ ฅ ์ค‘ ๋ฐœ์ƒfireEvent.input(screen.getByText("์ž…๋ ฅ"), { target: { value: '์ž…๋ ฅ ์ค‘' } })
doubleClick๋”๋ธ” ํด๋ฆญ ์ด๋ฒคํŠธfireEvent.doubleClick(screen.getByText("๋”๋ธ” ํด๋ฆญ"))
contextMenu์ปจํ…์ŠคํŠธ ๋ฉ”๋‰ด(์˜ค๋ฅธ์ชฝ ํด๋ฆญ)fireEvent.contextMenu(screen.getByText("์˜ค๋ฅธ์ชฝ ํด๋ฆญ"))
touchStartํ„ฐ์น˜ ์‹œ์ž‘ (๋ชจ๋ฐ”์ผ ํ™˜๊ฒฝ)fireEvent.touchStart(screen.getByText("ํ„ฐ์น˜ ์‹œ์ž‘"))
touchEndํ„ฐ์น˜ ์ข…๋ฃŒ (๋ชจ๋ฐ”์ผ ํ™˜๊ฒฝ)fireEvent.touchEnd(screen.getByText("ํ„ฐ์น˜ ์ข…๋ฃŒ"))
touchMoveํ„ฐ์น˜ ์ด๋™ (๋ชจ๋ฐ”์ผ ํ™˜๊ฒฝ)fireEvent.touchMove(screen.getByText("ํ„ฐ์น˜ ์ด๋™"))
scroll์Šคํฌ๋กค ๋ฐœ์ƒfireEvent.scroll(screen.getByText("์Šคํฌ๋กค"))
resize์ฐฝ ํฌ๊ธฐ ์กฐ์ •fireEvent.resize(window)

๊ธฐ๋ณธ์ ์œผ๋กœ๋Š” ํ•ด๋‹น ์š”์†Œ๋ฅผ ์ฐพ๊ณ , ์›ํ•˜๋Š” ์ด๋ฒคํŠธ๋ฅผ ๋ฐœ์ƒ์‹œ์ผœ์ฃผ๋ฉด ๋˜์ง€๋งŒ, ๋’ค์— ์ถ”๊ฐ€์ ์œผ๋กœ ์†์„ฑ์„ ์ž‘์„ฑํ•ด์•ผ ํ•˜๋Š” ๊ฒฝ์šฐ๋„ ์žˆ๋‹ค.

๐Ÿ’Ÿ target๊ณผ value

1
2
fireEvent.change(screen.getByLabel("์ด๋ฆ„"), { target: { value: "์‹ ์งฑ๊ตฌ" } })
fireEvent.input(screen.getByText("์ž…๋ ฅ"), { target: { value: "์ž…๋ ฅ ์ค‘" } })
  • target: ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•œ DOM ์š”์†Œ
  • value: ํ•ด๋‹น ์š”์†Œ์— ์ž…๋ ฅ๋œ ๊ฐ’

์˜ˆ๋ฅผ ๋“ค์–ด, ๋น„๋ฐ€๋ฒˆํ˜ธ input์„ ๊ฒ€์ฆํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•œ๋‹ค๊ณ  ์ƒ๊ฐํ•ด๋ณด์ž. ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜์†Œ๋ฌธ์ž์™€ ์ˆซ์ž๋งŒ ์ž‘์„ฑ ๊ฐ€๋Šฅํ•˜๋‹ค๋ฉด, ํŠน์ˆ˜๋ฌธ์ž ์ผ๋•Œ ์ œ๋Œ€๋กœ ์˜ค๋ฅ˜๋ฅผ ๋ฐœ์ƒํ•˜๋Š”์ง€ ๊ฒ€์ฆํ•ด์•ผ ํ•  ๊ฒƒ์ด๋‹ค.

์ฆ‰ ์ด๋ ‡๊ฒŒ โ€˜ํŠน์ • ๊ฐ’์˜ valueโ€™๋ฅผ ๊ฒ€์ฆํ•ด์•ผํ•  ๋–„ (ํ•„์š”ํ•  ๋–„) ํ•ด๋‹น value์— ํ…Œ์ŠคํŠธ ํ•ด์•ผ ํ•˜๋Š” ๊ฐ’์„ ๋„ฃ์–ด์ฃผ๋Š” ๊ฒƒ์ด๋‹ค.

๐Ÿ’Ÿ key์™€ code

  • key: ์‹ค์ œ๋กœ ์ž…๋ ฅ๋œ ํ‚ค
  • code: ๋ฌผ๋ฆฌ์ ์ธ ํ‚ค ์œ„์น˜
1
2
3
4
fireEvent.keyDown(screen.getByText("ํ‚ค๋ณด๋“œ ๋ˆ„๋ฆ„"), {
  key: "Enter",
  code: "Enter",
})

์ž˜ ์ดํ•ด๊ฐ€ ์•ˆ๊ฐ€๋Š”๋ฐ, ๋‘๊ฐ€์ง€ ์ƒํ™ฉ์„ ์ƒ๊ฐํ•ด๋ณด์ž.

  1. a์™€ A๋ฅผ ์ž…๋ ฅํ•  ๋•Œ

    • Shift ์—†์ด โ€˜aโ€™๋ฅผ ๋ˆ„๋ฅด๋ฉด: key: โ€˜aโ€™ code: โ€˜KeyAโ€™
    • Shift๋ฅผ ๋ˆ„๋ฅด๊ณ  โ€˜aโ€™๋ฅผ ๋ˆ„๋ฅด๋ฉด: key: โ€˜Aโ€™ (๋Œ€๋ฌธ์ž A) code: โ€˜KeyAโ€™ (๋ฌผ๋ฆฌ์ ์œผ๋กœ ๋™์ผํ•œ ํ‚ค)
  2. ๋‹ค๋ฅธ ๋ฐฐ์—ด์˜ ํ‚ค๋ณด๋“œ๋ฅผ ์ž…๋ ฅํ•  ๋•Œ ์šฐ๋ฆฌ๊ฐ€ ์ฃผ๋กœ ์‚ฌ์šฉํ•˜๋Š” ์ฟผํ‹ฐ ๋ฐฐ์—ด๊ณผ ๋‹ค๋ฅธ ํ‚ค๋ณด๋“œ์˜ ๊ฒฝ์šฐ ๊ฐ™์€ ์œ„์น˜๋ฅผ ํด๋ฆญํ•˜๋”๋ผ๋„ ์ž…๋ ฅ๋˜๋Š” ๊ฐ’์€ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ๋‹ค. (์ฃผ๋กœ code๋Š” ์ฟผํ‹ฐ ๊ธฐ์ค€์œผ๋กœ ์ •ํ•ด์ง„๋‹ค.)

โžก๏ธuserEvent

userEvent๋Š” Testing Library์˜ ๋™๋ฐ˜ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋กœ, ์‹ค์ œ ๋ธŒ๋ผ์šฐ์ €์—์„œ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” ์‚ฌ์šฉ์ž ์ƒํ˜ธ์ž‘์šฉ์„ ํ…Œ์ŠคํŠธํ•˜๋Š” ๋„๊ตฌ๋‹ค.

1
2
3
4
5
6
7
8
9
import userEvent from "@testing-library/user-event"

// ๊ฐ„๋‹จํ•œ ์‚ฌ์šฉ ์˜ˆ
it("click ์ด๋ฒคํŠธ ํ…Œ์ŠคํŠธ", async () => {
  const user = userEvent.setup()
  render(<MyComponent />)

  await user.click(screen.getByRole("button", { name: "ํด๋ฆญํ•˜์„ธ์š”!" }))
})

userEvent๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์ „ userEvent.setup()์„ ์‚ฌ์šฉํ•ด ์‚ฌ์šฉ์ž ์ƒํ˜ธ์ž‘์šฉ์„ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ํ•  ์ˆ˜ ์žˆ๋Š” ํ™˜๊ฒฝ์„ ์„ค์ •ํ•˜๊ณ , ์ „์ฒด์ ์ธ ์‚ฌ์šฉ๋ฒ•์€ fireEvent์™€ ๋™์ผํ•˜๊ฒŒ ์ง„ํ–‰๋œ๋‹ค.

โžก๏ธuserEvent vs fireEvent

๊ทธ๋Ÿผ ๋‘๊ฐ€์ง€์˜ ์ฐจ์ด์ ์€ ๋ฌด์—‡์ผ๊นŒ?

fireEvent๋Š” ๋ธŒ๋ผ์šฐ์ €์˜ ์ €์ˆ˜์ค€ API์ธ dispatchEvent๋ฅผ ๊ฒฝ๋Ÿ‰์œผ๋กœ ๊ฐ์‹ธ ์‚ฌ์šฉ์ž๊ฐ€ ์›ํ•˜๋Š” ์ด๋ฒคํŠธ๋ฅผ ์ง์ ‘ ํŠธ๋ฆฌ๊ฑฐํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•œ๋‹ค. ํ•˜์ง€๋งŒ ๋ธŒ๋ผ์šฐ์ €์—์„œ์˜ ์ด๋ฒคํŠธ๋Š” ์—ฌ๋Ÿฌ ์ด๋ฒคํŠธ๊ฐ€ ์—ฐ์†์ ์œผ๋กœ ๋ฐœ์ƒํ•˜๊ธฐ๋„ ํ•œ๋‹ค.

์˜ˆ๋ฅผ๋“ค์–ด ์‚ฌ์šฉ์ž๊ฐ€ ํผ์„ ์ด์šฉํ•˜๊ณ ์ž ํ•˜๋ฉด input์— focusํ•˜๊ณ , ๊ฐ’์„ ์ž…๋ ฅํ•ด ๋ณ€๊ฒฝ๋˜๋ฉฐ, ๋™์‹œ์— ํ‚ค๋ณด๋“œ ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜๋Š” ๊ฒƒ์„ ์ƒ๊ฐํ•ด๋ณผ ์ˆ˜ ์žˆ๋‹ค.

user-event๋Š” fireEvent์ฒ˜๋Ÿผ ํ•˜๋‚˜์˜ ์ด๋ฒคํŠธ๊ฐ€ ์•„๋‹ˆ๋ผ, ์‚ฌ์šฉ์ž์˜ ์ƒํ˜ธ์ž‘์šฉ์„ ํ…Œ์ŠคํŠธ ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋ฉฐ, ๋ธŒ๋ผ์šฐ์ €์—์„œ ์ผ์–ด๋‚˜๋Š” ๊ฒƒ์ฒ˜๋Ÿผ DOM์„ ์กฐ์ž‘ํ•  ์ˆ˜ ์žˆ๋‹ค.

๋”ฐ๋ผ์„œ ๊ณต์‹๋ฌธ์„œ์—์„œ๋Š” user-event๋ฅผ ์‚ฌ์šฉํ•˜๋„๋ก ๊ถŒ์žฅํ•œ๋‹ค. ๋‹ค๋งŒ ์•„์ง ์™„๋ฒฝํ•˜๊ฒŒ ๋ชจ๋“  ์ด๋ฒคํŠธ๊ฐ€ ๊ตฌํ˜„๋œ ๊ฒƒ์€ ์•„๋‹ˆ์–ด์„œ, ์ด ๊ฒฝ์šฐ fireEvent๋ฅผ ์‚ฌ์šฉํ•ด ๋ณด์™„์ ์ธ ์ธก๋ฉด์— ์žˆ๋‹ค.

๐Ÿ—‚๏ธ์ฐธ๊ณ  ์‚ฌ์ดํŠธ

This post is licensed under CC BY 4.0 by the author.