위와 같은 컴포넌트는 style만 정의되어 있고 business logic(onClick)은 다른곳에서 주입받는다고 가정해보자. (hook이나 전역 state를 사용할수도 있고 방법은 여러가지)
onClick이라는 함수를 jest에서 jest.fn()로 mocking하고
tobeCalled(), tobeCalledWith()등 호출을 확인하는 함수를 사용하면
테스트 상황이 주어졌을때 실제로 business logic을 올바르게 호출하는지 확인 가능하다.
너무 내부로직이 비대해서 쪼갠 케이스인데 이런 케이스는 잘 없지 싶다
잘하는법? (나도 몰라..)
테스트는 비용이다 -> 불필요한 테스트 최소화 -> 동어반복적 테스트 방지
부작용을 최소화하자
로직을 순수함수로 작성한다면 항상 input이 동일하다면
마지막 output이 동일할꺼라 예측 가능하다.
즉, 부작용(side Effect)가 없는 순수함수의 조합으로 코드를 작성하다면
테스트시 end-to-end로 테스트가 가능하니 테스트 작성 & 유지보수가 쉬워짐
다만 부작용이 없는 코드는 나오기 어려우므로
UI <-> business logic <-> data(접근) 이라고 나눠본다면
business logic를 순수함수로 작성하고 테스트하려고 노력하고,
UI, data 부분에 부작용 함수 부분을 넘겨주자..?
FIRST 법칙
F (Fast 빠르게) I (Independat 독립적으로 - sideEffect 영향 X) R (Repeatable 반복가능하게 - 결과가 매번 동일) S (Self-Validating 자가검증하는 - 결과가 True or False) T (Timely 적시에 - 필요할때)
코드(테스트 대상) 짤때 SOLID 법칙 응용
약간 다르긴 하지만 본질은 비슷하다
핵심 -
함수는 하나의 기능만
모듈화(ex - 라이브러리 교체시 인터페이스는 놔두고 통째로 교체)
컴포넌트 간의 의존성 제거
로직과 스타일의 분리 (container-presenter패턴, hook 등등)
Given, When, Then 패턴 사용
test('button click event', () => {
// Given
const data = $4
const spyOn = jest.fn();
render(<button type="button" onClick={spyOn} />)
// When
const button = screen.getByRole('button');
button.click();
// Then
expect(spyOn).tohaveBeenCalled();
})
가독성 좋은 테스트는 기능 명세서 역할도 가능하다
유저의 행동에 중점을 두고 작성
Given -> 주어진 환경
When -> Event Trigger
Then -> Effect Result
BDD에서 파생된 개념인거 같은데 깔끔하게 시나리오를 짤 수 있어서 좋은거 같다
라이브러리 테스트(optional)
필수는 아니지만 라이브러리 버전업(or 교체)시 무엇이 변경되었는지(or 무엇이 라이브러리 종속적인지)
빠르게 알기 쉽도록 라이브러리 관련 코드에 테스트 작성
또한 라이브러리(ex-버튼, 테이블 라이브러리 등등)을 사용한다면 모듈화하고 테스트를 작성해놓으면
이전에 유저의 명령을 undo, redo 하기 위해서 유저가 실행한 커맨드 하나하나를 기록하여
스택에 쌓는 작업을 했었다.
그런데 다양한 인터렉션을 처리하려고 하다보니 여러 원자적인 연산(create, delete, update)들을 조합해 하나의 커맨드로 기록할 필요성이 생기게 되었다.
예를 들어 다음 예시 화면과 같이
부모 노드안에 자식 노드를 추가하려면 한번 추가할때마다 각각 두번의 연산이 필요하다.
1. update 연산 -> 부모 노드(시작 스탭)의 width, height가 커져야 한다.
2. add 연산 -> 자식 노드를 nodes 배열에 추가해야한다.
(undo, redo할땐 두 연산을 하나로 합쳐서 작동해야한다.)
이것을 간단한 코드 예시로 보자면 다음과 같다.
const [nodes, setNodes] = useRecoilState(flowNodesState); // 배열 형태
const [edges, setEdges] = useRecoilState(flowEdgesState);
const addChildNode = () => {
....
// this should be updateNodes
setNodes((nodes: Node[]) => 부모 노드 크기 변경)
// this should be addNodes
setNodes((nodes: Node[]) => 새로운 자식 추가)
}
setState 함수에서 인자를 이용하면 비록 setState가 비동기라도
기존 state가 아닌 새로 갱신된 state를 인자로 받아 마치 차례대로 실행하는것처럼 사용할 수 있다.
식으로 비유하자면
A : 처음 node State
F(x) : update 연산
G(x) : add 연산
새로운 상태 : G(F(A))
문제점
그래서 setState를 히스토리를 기록하기 위해서 command 함수들로 바꾸면
내부적으로는 setState를 커맨드 하나마다 각각 실행하니 기존과 동일하게 실행되지 않을까? 하는 가설을 세웠지만 실제로 실행해보니 기대처럼 작동하지는 않았다.
setState를 실행할때마다 한 프레임이라고 정의한다면 undo를 하면 이전 프레임으로 상태를 복귀시키고, Redo하면 다시 undo 했던 명령을 취소해 다시 기존 프레임으로 돌아와야 할것이다.
각 프레임이 변경될때마다 전체 state(node, edge)를 프레임 별로 저장해놓는 것은 메모리 용량 이슈가 발생하는것 같아서 유저가 실행한 커맨드를 순서대로 기억해두고(stack) undo시 해당 실행 커맨드를 역산하고, redo시 다시 실행하자 라는 가설을 세우고 코드를 작성해보았다.
즉, command 단위로 명령어를 저장했다.
이를 위해 유저의 명령을 event라는 하나의 단위로 추상화하고, 이벤트가 실행할때마다 커맨드를 기록하는 식으로 구현하했다.
예를 들어 create, create, create라는 명령이 3번 온다면
우선 undo stack에 [create, create, create] 라는 형식으로 3번 쌓이게 된다.
그리고 undo 명령을 실행하면 undo stack에서 순서대로 명령어를 하나씩 꺼내어 그 명령의 역 커맨드를(역산) 실행하는데
undoStack = [];
redoStack = [];
// 커맨드 기록
function record(command){
undoStack.push(command);
}
//undo 실행
function undo(){
redoStack.push(undoStack.pop());
}
//redo 실행
function redo(){
undoStack.push(redoStack.pop());
}
복잡한 비동기 로직을 사용할때 Suspense를 사용하면 데이터를 사용하는 컴포넌트와 로딩 & 에러 상태를 핸들링하는 방식을 분리시켜 선언적으로 프로그래밍이 가능하다.
react-query나 recoil에서 suspense를 사용하는 방식을 지원해주는데 라이브러리의 지원이 없이 직접 처리하는 방식이 궁금해서 해보았다.
우선 suspense에 대해 인터넷에 찾아보니 나온 코드는 다음과 같다.
function wrapPromise(promise: AxiosPromise) {
let status = "pending";
let result = {};
const suspender = promise.then(
(successResult) => {
status = "success";
result = successResult;
},
(errorResult) => {
status = "error";
result = errorResult;
}
);
return function read() {
switch (status) {
case "pending":
throw suspender;
case "error":
throw result;
case "success":
default:
return result;
}
};
}
function fetchDataVersion1(requestAPI: Promise) {
return wrapPromise(requestAPI);
}
const Main = () => {
return (
<Suspense fallback={<div>loading...</div>}>
<Dddd />
</Suspense>
);
};
let count = 0;
setInterval(() => {
count += 1;
}, 1000);
const axiosRequestExample = new Promise((res) => {
setTimeout(() => {
res(`${count} is shown`);
}, 6000);
});
const requestResource = fetchDataVersion1(axiosRequestExample);
const Dddd = () => {
const data = requestResource();
return <div>{JSON.stringify(data)}</div>;
};
export default Main;
로직을 간단히 설명하자면,
function wrapPromise(promise: AxiosPromise) {
let status = "pending";
let result = {};
const suspender = promise.then(
(successResult) => {
status = "success";
result = successResult;
},
(errorResult) => {
status = "error";
result = errorResult;
}
);
return function read() {
switch (status) {
case "pending":
throw suspender;
case "error":
throw result;
case "success":
default:
return result;
}
};
}
비동기 데이터를 처리중인 상태에는 상위 컴포넌트로 promise를 넘겨서 Suspense가 처리하게 만들고, (동일하게 error도 상위 컴포넌트로 넘겨서 ErrorBoundary(직접 구현해야함)가 처리하게 함)
데이터가 도착하면 렌더링 하는식으로
Suspense를 이용하면 데이터를 불러오는 event(what)과 어떻게 처리하는 로직(how)을 분리시켜 선언적으로 처리하기 쉽게 만들어주는 강력한 도구가 될 수 있다.
3. Capture phase events (e.g.onClickCapture) 가 이제 진짜 캡처 페이즈 리스너 사용 (이전엔 어케했누?)
이벤트 풀링 최적화 제거 (No Event Pooling)
“event pooling” optimization이 별 의미없어서 삭제
비동기적으로 이벤트 객체에 접근 불가능했던게 삭제됨
function handleChange(e) {
setData(data => ({
...data,
// This crashes in React 16 and earlier:
text: e.target.value <- before v17 : null
}));
}
난 2021 입문자라(react 17 이후 사용) event pooling이 무슨 역할이었는지 정확히는 모르겠다
SyntheticEvent 객체는 이벤트 핸들러가 호출된 후 초기화되기에, 비동기적으로 이벤트 객체에 접근할 수 없었던 것이 수정되었다고 한다.
아마 setData 실행시기와 e의 생명주기가 일치하지 않았던것(초기화되버려서)으로 추론된다.
Effect Cleanup Timing
useEffect(() => {
// This is the effect itself.
return () => { // This is its cleanup. };});
useEffect가 보다 효율적으로 변경
기본 컨셉 :
Most effects don’t need to delay screen updates, so React runs them asynchronously soon after the update has been reflected on the screen.
(For the rare cases where you need an effect to block paint, e.g. to measure and position a tooltip, preferuseLayoutEffect.)
그러나 보통은 unmounting 될때 cleanup function은 보통 동기적으로 실행되게 사용되어 왔다.
(similar tocomponentWillUnmountbeing synchronous in classes)
이것은 탭 변경과 같은 큰 페이지 변경시 속도 저하를 초래했다.
그래서 React 17에서는 항상 cleanup function은 비동기적으로 실행되게 변경되었다.
(In React 17, the effect cleanup function always runs asynchronously — for example, if the component is unmounting, the cleanup runsafterthe screen has been updated.)
동기적 상황을 원하면 useLayoutEffect를 쓰시라
(you might want to rely on the synchronous execution, you can switch touseLayoutEffectinstead)
추가적으로 cleanup function은 언제나 new Effect가 실행하기 전에 실행된다.
let Button = forwardRef(() => {
// We forgot to write return, so this component returns undefined.
// React 17 surfaces this as an error instead of ignoring it.
<button />;
});
let Button = memo(() => {
// We forgot to write return, so this component returns undefined.
// React 17 surfaces this as an error instead of ignoring it.
<button />;
});
return undefined하면 에러
(제대로 return 안하면 에러)
만약 아무것도 return 하지 않는 것을 명시적으로 나타내고 싶다면 return null 사용