Web3 세상에서는 중앙 서버가 해체되어 존재하지 않게 됩니다 (Decentralized).

Web3 세상은 중앙 서버가 아니라 P2P 프로토콜에 의해 지탱됩니다.

Web3 세상에서는 웹 브라우저가 어플리케이션 실행 플랫폼 역할을 수행할 것이라고 생각합니다.

그래서 웹 프론트엔드 기술이 중요해질 것이라고 생각합니다.

그런 맥락에서 Web3 프론트엔드 어플리케이션의 e2e 테스트에 대해 소개합니다.

Cypress 와 Synpress, 두 단어만 기억하시면 성공입니다.

 

 

e2e 테스트가 필요한 순간

  • TDD (Test Driven Development) 방식으로 Web3 어플리케이션을 개발하고 있었음
  • 터미널 한쪽 구석에 Unit Test를 실행시켜 놓고 소스 파일을 수정할 때마다 모든 Unit Test가 성공하는지 확인하며 개발했음
  • 하지만, 모든 Unit Test를 통과했음에도 버그가 발생하는 상황을 겪음
  • 서버, 네트워크, 브라우저-애드온 같은 모든 컴포넌트들이 참여하는 상황에서만 확인 가능한 버그가 있었음

 

 

e2e 테스트 도구, Cypress

  • 현재 많이 쓰이는 e2e 테스트 도구는 Cypress Selenium
  • Cypress는 브라우저 안에서 일어나는 거의 모든 일을 JavaScript로 확인 가능한 개발자 친화적 도구
  • 반면 Selenium은 QA 엔지니어 친화적 도구

 

 

Web3 어플리케이션 테스트를 위한 Cypress 플러그인, Synpress

  • Web3 어플리케이션은 MetaMask 브라우저 애드온이 있어야 동작
  • MetaMask 브라우저 애드온이 개입하는 사용자 시나리오를 테스트하기 위해 Synpress가 필요 (MetaMask 활성화 시나리오, MetaMask wallet-connect 승인 시나리오, MetaMask TX 승인 시나리오, ...)

 

 

Cypress & Synpress 테스트 팁

  • Cypress 테스트 구문은 query-assert 또는 query-command 형태
    • Cypress 구문은 assert나 command로 마무리 짓는 것이 Good Practice (assert나 command 이후에 query를 덧붙이지 않는다)
  • 이더리움 메인넷을 타겟으로 하는 테스트는 Synpress의 task setupMetamask 단계에서 실패함
    • Synpress가 MetaMask 애드온을 설정한 다음, 성공 여부를 판정하기 위해 mainnet 문구를 찾지만, 한글 브라우저에서 메인넷이라고 표시되기 때문에 문자열 불일치로 인한 assertion fail이 발생한다
    • 메인넷 대신 테스트넷을 타겟으로 테스트 시나리오를 작성하는 편이 좋다 (한글 브라우저에서도 Goerli는 고얼리라고 표시하지 않고 Goerli라고 표시한다)

 

 

그래서 e2e 테스트는?

  • 모든 컴포넌트가 참여하는 상황에서만 확인할 수 있는 버그가 있음. 이를 잡아내는 것이 e2e 테스트
  • Unit Test 수행 시간은 msec 단위, e2e 테스트 수행시간은 min 단위 (테스트 비용이 크다)
  • 프론트엔드 테스트 피라미드가 제시하는 바대로 e2e 테스트는 아껴 써야 함

출처: "Testing Vue.js Applications"/ Edd Yerburgh 지음/ Manning Publications 펴냄

 

 

Ref.

별 4개급 참조 문서

별 3개급 참조 문서

 

Posted by ingeeC
,

도전! 프론트엔드 TDD

Dev 2023. 5. 16. 13:05

웹3 세상의 핵심 플랫폼은 브라우저일 거라고 생각합니다. 그래서 프론트엔드 개발이 중요하다고 생각합니다.

작은 프로젝트를 진행하면서 프론트엔드 TDD를 경험했습니다. TDD는 테스트 코드와 구현 코드를 함께 개발해 나가는 방식입니다. 간단한 Todo 앱을 예로 들어 제가 경험한 TDD를 설명하려 합니다.

프론트엔드 TDD 개념과 Vue3 & Pinia 테스트 코드 작성 사례를 전달하는 것이 목표입니다. Vue3 & Pinia 프로젝트 개발에 도움이 되면 좋겠습니다.

 

관련 코드는 GitHub https://github.com/ingee/todo 소스 레포에 있습니다.

 

웹앱 구상

  • 웹앱을 스케치합니다. todo를 등록하고 삭제하는 기능이 전부입니다.

 

스캐폴딩

  • npm init vue@latest 명령으로 Vue3와 Pinia를 이용하는 웹앱의 뼈대를 만듭니다.
$ node -v
v16.20.0

$ npm init vue@latest

Vue.js - The Progressive JavaScript Framework

✔ Project name: … todo
✔ Add TypeScript? … No
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … No
✔ Add Pinia for state management? … Yes
✔ Add Vitest for Unit Testing? … Yes
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … No

Scaffolding project in /home/ingee/work/todo/todo...

Done. Now run:

  cd todo
  npm install
  npm run dev

 

1. Pinia 스토어에 있는 todo 목록을 화면에 표시하자

목표

  • Pinia 스토어에 todos라는 이름으로 todo 목록을 저장할 계획입니다.
  • Pinia 스토어에 저장되어 있는 todo 목록을 화면에 표시하는 기능부터 구현하기로 합니다.

1.1 Unit Test를 작성합니다

src/ __tests__/ App.spec.js

import { describe, it, expect, vi } from 'vitest'
import { shallowMount, flushPromises } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import merge from 'lodash.merge'
import App from '@/App.vue'

describe('App.vue', () => {
  function createWrapper (option) {
    const pinia = createTestingPinia({
      initialState: option
        ? {
            todo: option.initialState,
          }
        : {},
      createSpy: vi.fn,
    })
    const defaultOpt = {
      global: {
        plugins: [pinia],
      },
    }
    return shallowMount(App, merge(defaultOpt, option))
  }

  it('shows todos in store', () => {
    const todos = [
      'wake up',
      'wash up',
      'go to work',
    ]
    const initialState = { todos }
    const wrapper = createWrapper({ initialState })
    for (const todo of todos) {
      expect(wrapper.text()).toContain(todo)
    }
  })
})
  • 테스트 wrapper 객체를 생성하기 위해 createWrapper() 함수를 작성합니다.
  • Pinia 스토어의 초기 상태를 지정하기 위해 createWrapper() 함수에 전달하는 option 객체를 이용합니다 (어디선가 솜씨 좋은 개발자로부터 배운 방식입니다).
  • 기능이 정상 동작하는지 검증하기 위해 "It shows todos in store" 유닛 테스트를 작성합니다.

1.2 실패 확인

  • 콘솔창에서 npm run test::unit 명령을 실행하고, 유닛 테스트가 실패함을 확인합니다 (아직 구현하지 않았으니 실패가 당연합니다).
  • 적절한 사유와 함께 실패함을 확인합니다.
    • Pinia의 초기 상태로 지정한 todo 목록이 화면에 표시되지 않는다고 합니다. 적절합니다.
 ❯ src/__tests__/App.spec.js (1)
   ❯ App.vue (1)
     × shows todos in store

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/__tests__/App.spec.js > App.vue > shows todos in store
AssertionError: expected 'Todo Lorem Ipsum is simply dummy text…' to include 'wake up'
 ❯ src/__tests__/App.spec.js:35:30
     33|     const wrapper = createWrapper({ initialState })
     34|     for (const todo of todos) {
     35|       expect(wrapper.text()).toContain(todo)
       |                              ^
     36|     }
     37|   })

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 Test Files  1 failed (1)
      Tests  1 failed (1)
   Start at  13:08:02
   Duration  90ms


 FAIL  Tests failed. Watching for file changes...
       press h to show help, press q to quit

1.3 기능 구현 & 성공 확인

 ✓ src/__tests__/App.spec.js (1)

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  13:11:57
   Duration  108ms


 PASS  Waiting for file changes...
       press h to show help, press q to quit

 

2. 입력창에 입력한 todo를 Pinia 스토어에 저장하자

목표

  • 화면에 입력한 todo를 Pinia 스토어에 저장할 계획입니다.
  • todo를 입력하고 Add 버튼을 클릭하면, 해당 항목이 Pinia 스토어에 저장되게 하기로 합니다.

2.1 Unit Test를 추가합니다

src/ __tests__/ App.spec.js

  it('adds todo-item when add-btn is clicked', async () => {
    const wrapper = createWrapper()

    const todo = 'have lunch'
    const todoInput = wrapper.find('[data-test="todo-input"]')
    await todoInput.setValue(todo)

    const addBtn = wrapper.find('[data-test="add-btn"]')
    await addBtn.trigger('click')

    const store = useTodoStore()
    expect(store.addTodo).toBeCalledWith(todo)
  })
  • "It adds todo-item when add-btn is clicked" 유닛 테스트를 추가합니다.
  • 테스트 wrapper의 setValue() 함수로 화면 입력 값을 지정합니다.
  • Add 버튼을 클릭하면 store의 addTodo() 함수가 적절한 인자와 함께 호출되는지 확인합니다.

2.2 실패 확인

  • 유닛 테스트가 적절한 사유와 함께 실패함을 확인합니다.
    • todo 입력 필드가 없다고 합니다. 적절합니다 (아직 구현하지 않았으니 당연합니다).
 ❯ src/__tests__/App.spec.js (2)
   ❯ App.vue (2)
     ✓ shows todos in store
     × adds todo-item when add-btn is clicked

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/__tests__/App.spec.js > App.vue > adds todo-item when add-btn is clicked
Error: Cannot call setValue on an empty DOMWrapper.
 ❯ Object.get node_modules/@vue/test-utils/dist/vue-test-utils.esm-bundler.mjs:1517:27
 ❯ src/__tests__/App.spec.js:44:21
     42|     const todo = 'have lunch'
     43|     const todoInput = wrapper.find('[data-test="todo-input"]')
     44|     await todoInput.setValue(todo)
       |                     ^
     45|
     46|     const addBtn = wrapper.find('[data-test="add-btn"]')

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)
   Start at  13:35:46
   Duration  121ms

2.3 기능 구현 & 성공 확인

 ✓ src/__tests__/App.spec.js (2)

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  13:45:47
   Duration  160ms


 PASS  Waiting for file changes...
       press h to show help, press q to quit

 

3. 화면에 표시된 todo 항목을 제거하자

목표

  • todo 항목마다 옆에 X 버튼을 표시할 계획입니다.
  • X 버튼을 클릭하면 해당 항목이 삭제되게 하기로 합니다.

3.1 Unit Test를 추가합니다

src/ __tests__/ App.spec.js

  async function setStore (state) {
    const store = useTodoStore()
    store.$patch(state)
    await flushPromises()
    return store
  }
  ...
  
  it('removes todo-item when rm-btn is clicked', async () => {
    const todo = 'something to do'
    const wrapper = createWrapper({
      initialState: {
        todos: [ todo ],
      },
    })
    let rmBtns = wrapper.findAll('[data-test="rm-btn"]')
    expect(rmBtns.length).toBe(1)
    await rmBtns.at(0).trigger('click')
    let store = useTodoStore()
    expect(store.rmTodo).toBeCalledWith(0)

    const todos = [
      'something #1',
      'something #2',
      'something #3',
    ]
    store = await setStore({ todos })
    rmBtns = wrapper.findAll('[data-test="rm-btn"]')
    expect(rmBtns.length).toBe(todos.length)
    await rmBtns.at(1).trigger('click')
    expect(store.rmTodo).toBeCalledWith(1)
  })
  • "It removes todo-item when rm-btn is clicked" 유닛 테스트를 추가합니다.
  • createWrapper() 함수를 호출할 때 option.initialState 인자를 전달해서 Pinia 스토어의 초기 상태를 지정합니다.
  • 유닛 테스트 도중 Pinia 스토어의 상태를 변경하기 위해 setStore() 함수를 작성합니다.

3.2 실패 확인

  • 유닛 테스트가 적절한 사유와 함께 실패함을 확인합니다.
    • X 버튼 (rm-btn)이 없다고 합니다. 적절합니다.
 ❯ src/__tests__/App.spec.js (3)
   ❯ App.vue (3)
     ✓ shows todos in store
     ✓ adds todo-item when add-btn is clicked
     × removes todo-item when rm-btn is clicked

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/__tests__/App.spec.js > App.vue > removes todo-item when rm-btn is clicked
AssertionError: expected +0 to be 1 // Object.is equality
 ❯ src/__tests__/App.spec.js:68:27
     66|     })
     67|     let rmBtns = wrapper.findAll('[data-test="rm-btn"]')
     68|     expect(rmBtns.length).toBe(1)
       |                           ^
     69|     await rmBtns.at(0).trigger('click')
     70|     let store = useTodoStore()

  - Expected   "1"
  + Received   "0"

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

 Test Files  1 failed (1)
      Tests  1 failed | 2 passed (3)
   Start at  13:49:05
   Duration  106ms

3.3 기능 구현 & 성공 확인

 

마무리

지금까지의 내용을 요약합니다.

 

TDD는,

  • 테스트 코드를 먼저 만들고,
  • 그 테스트 코드가 적절한 사유와 함께 실패함을 확인하고,
  • 그 테스트 코드가 성공하도록 구현하고, 이 과정을 반복하는 개발 방식입니다.

Vue3 & Pinia 웹앱의 유닛 테스트 코드를 작성함에 있어,

  • Pinia 스토어의 초기 상태를 지정하는 방법을 알아 보았습니다.
  • 웹 화면의 입력창에 사용자 입력값을 지정하는 방법을 알아 보았습니다.
  • 테스트 도중 Pinia 스토어의 상태를 변경하는 방법을 알아 보았습니다.

테스트 코드는 쉽게 작성해서 자주 써먹어야 합니다. 하지만 처음 시작하자면 넘어야 할 장벽이 있습니다. 이 글이 그 장벽을 넘는 데 도움이 되기를 희망합니다.

관련 코드는 GitHub https://github.com/ingee/todo 소스 레포에 있습니다.

 

Ref.

Posted by ingeeC
,

작업 동기

트위터를 즐겨 쓴다. 나름 헤비 유저다. 트위터는 옛날부터 키보드 사용자를 배려해왔다. 트위터 화면에서 J,K 키를 입력하면 화면을 아래,위로 스크롤할 수 있다. VIM 사용자 입장에서 아주 자연스럽고 쾌적하다. 그런데 가끔 딱 시야를 방해하는 그 자리에 "새 트윗 보기" 팝업이 뜬다. 일단 팝업이 뜨면 가독성이 현격히 떨어진다. 쾌적하지 못하다.

 

그림 : 문제 현상

 

해결책을 찾기 위해 노력했다. 브라우저 개발자 도구를 열고 범인을 찾았다. 그리고 해당 html 요소를 투명하게 만들면 해결 가능함을 확인했다. 하지만 트위터를 켤 때마다 개발자 도구를 열고 콘솔창에서 "화면 청소 코드"를 실행시키는 것은 사람이 할 짓이 아니었다. 이런 건 분명 기계에게 시켜야 옳은 일이다. 그리고 나만 불편할까? 이 기능을 브라우저 애드온으로 만들어 공개한다면 널리 세상을 이롭게 하는, 단군 할아버지께서 기뻐하실 만한 일이 되지 않을까?

 

그림 : 범인

 

해결책 : 트위터 화면 청소 코드

obj = document.getElementsByClassName('r-dkhcqf'); for (o of obj) { o.style.opacity = 0.1 }

 

참조 코드를 찾자

모방은 창조의 어머니. 가장 먼저 할 일은 참조 코드를 찾는 일이다. 파이어폭스를 주력 브라우저로 쓰는 입장에서 아주 그럴듯한 참조 코드를 찾았다. webextensions-examples. 이름처럼 다양한 애드온 샘플로 구성된 프로젝트다. 제시된 애드온 샘플 중에서 borderify 샘플이 내게 잘 맞는다고 생각했다. borderify 애드온은 *.mozilla.org 페이지를 표시할 때마다 붉은색 테두리를 덧붙여 표시하는 애드온이다. 특정 url에 반응한다는 점(즉 트위터 url에 반응하는 애드온을 만들 수 있다는 점)과 브라우저 컨텐트를 대상으로 스크립트를 실행한다는 점(즉 트위터 화면 청소 코드를 실행시키는 애드온을 만들 수 있다는 점)이 내가 원하던 바였다.

 

몇 차례 시행착오 끝에 애드온 소스 코드를 완성했다.

애드온 GitHub 소스 레포

 

파이어폭스 주소창에서 about:debugging을 입력하고, "임시 부가 기능 로드..." 버튼을 선택해서 manifest.json 파일을 선택하면, 브라우저에서 애드온을 실행시켜 볼 수 있다. 그렇게 애드온이 정상 동작함을 확인했다.

 

그림 : 애드온 개발 설정

 

이제 개발한 애드온을 정식 등록할 차례다. 그러면 브라우저를 실행시킬 때마다 "임시 부가 기능 로드..." 버튼을 클릭하지 않아도 된다. 그리고 집에 있는 컴퓨터 뿐 아니라 회사에 있는 컴퓨터에서도 이 애드온을 사용할 수 있게 된다. 절차는 간단했다. AMO 개발자 허브에 등록하면 된다. 소스 코드를 zip 파일로 압축해서 클릭 몇 번만 하면 등록이 완료된다. 그러면 24 시간 이내에 처리 결과를 알려주겠다는 친절한 메일이 온다. 그리고 정말 그 다음날 애드온이 등록됐다.

 

그림 : AMO에 정식 등록된 애드온

 

그림 : 파이어폭스에 정식 설치된 애드온

 

브라우저 애드온은 나름 표준 웹기술이어서 파이어폭스 애드온을 조금만 다듬으면 크롬 브라우저에서도 사용 가능하다. 크롬 브라우저를 위한 나머지 일은 다른 훌륭한 사람에게 미룬다. 브라우저로 할 수 있는 재미난 일이 더욱 많아지기를 희망한다. 웹3 세상의 주력 플랫폼은 브라우저일 것이다.

 

 

Posted by ingeeC
,