How to test a component with updates background depend on state changes - reactjs

I have a component that changes the background image depending on the state. I added simplified codes down below.
Since I fetch an image from the server on state changes, the background image was flashing. This is the reason I load them to DOM with preloadImage() function. This function solved the issue.
The problem starts with testing. See the testing file!
const BackgroundImage = styled`
...
background-image: ${(props) => props.bg && `url(${props.bg})`};
`
const preloadImage = (src, wrapperRef, callback) => {
const img = new Image();
img.src = src;
img.style.display = 'none';
img.dataset.testid = 'preloaded-image';
const el = wrapperRef.current;
el.innerHTML = '';
el.appendChild(img);
img.onload = () => typeof callback === 'function' && callback(src);
};
const Panel = (defaultBG) => {
const imageCacheRef = useRef();
const [bg, setBG] = useState(defaultBG);
useEffect(() => {
const fetchImage = async () => {
const imageSrc = await import(`https://fakeimageapi.com/${bg}.png`);
return preloadImage(imageSrc.default, imageCacheRef, setImage);
}
try {
await fetchImage()
} catch (error) {
console.log(error)
}
}, [])
return (
<div ref={imageCacheRef}>
<BackgroundImage bg={bg} data-testid="bg" />
<button onClick={ () => setBG('cat') }>Cat</button>
<button onClick={ () => setBG('dog') }>Cat</button>
<button onClick={ () => setBG('rabbit') }>Cat</button>
<button onClick={ () => setBG('parrot') }>Cat</button>
</div>
)
}
This is the test suite written with Testing Library.
import { render, waitFor, screen, act } from '#testing-library/react';
describe('Panel', () => {
test('Sets background-image correctly', async () => {
render(<Panel defaultBG="panda" />)
expect(screen.getByTestId('bg')).toHaveStyle(
'background-image: url(panda.png);',
);
})
})
Unfortunately, this test fails. The problem (I guess) that I use a callback after the image is loaded inside useEffect. How can I final this test with a successful result?

The problem is solved. I added a test-id to the image inside preloadImage() and loaded the image with the fireEvent method. That's it!
import { render, waitFor, screen, fireEvent } from '#testing-library/react';
describe('Panel', () => {
test('Sets background-image correctly', async () => {
render(<Panel defaultBG="panda" />)
const image = await waitFor(() => screen.getByTestId('preloaded-image'));
fireEvent.load(image);
expect(screen.getByTestId('bg')).toHaveStyle(
'background-image: url(panda.png);',
);
})
})
Also, some refactoring on preloadImage() function.
const preloadImage = (src, wrapperRef, callback) => {
const img = new Image();
img.src = src;
img.style.display = 'none';
img.dataset.testid = 'preloaded-image';
const el = wrapperRef.current;
el.innerHTML = '';
el.appendChild(img);
if (typeof callback === 'function') {
img.onload = () => callback(src);
}
};

Related

Testing a window event addEventListener with a callback using enzyme, mocha and chai

I am having trouble testing the callback in my window addEventListener that's wrapped in a useEffect. I am unable to get coverage for the callback setHighRes and also the
...
return () => {
window.removeEventListener("ATF_DONE", setHighRes);
};
JSX file
// Checks to see if image is cached
const isCached = src => {
const img = new Image(); // eslint-disable-line
img.src = src;
const complete = img.complete;
img.src = "";
return complete;
};
const [isHighRes, lazySetIsHighRes] = useState(
!isCached(`${images[0].normal}?wid=200&hei=200`)
);
useEffect(() => {
// If image is cached load the high res image after the ATF_DONE event
if (!isHighRes) {
const setHighRes = () => {
lazySetIsHighRes(true);
window.removeEventListener("ATF_DONE", setHighRes);
};
window.setTimeout(() => {
window.addEventListener("ATF_DONE", setHighRes);
return () => {
window.removeEventListener("ATF_DONE", setHighRes);
};
}, ATF_TIMEOUT)
}
return null;
}, []);
I tried doing this in the spec.jsx but it fails because expected addEventListener to have been called with arguments
beforeEach(() => {
sandbox = sinon.sandbox.create();
sinon.stub(window, "addEventListener");
sinon.stub(window, "removeEventListener");
sandbox.stub(window, "Image").callsFake(() => {
const image = new Image.wrappedMethod();
sandbox.stub(image, "complete").value(complete);
return image;
});
it.only("should load low res image if image is cached", () => {
const clock = sinon.useFakeTimers();
clock.tick(8000);
expect(window.addEventListener).to.have.been.calledWith("ATF_DONE", "setHighRes");
})
Looks like you can manually dispatch the event to trigger the callback which worked for me
window.dispatchEvent(new Event("ATF_DONE"));

React: Show Error Modal if uploaded image file is too large

I want to show a notification Modal if an image which I want to upload is too large. My problem is, that the handleUpload function is an external function. How can I render the modal?
My parent component:
const MyParent = () => {
const boundHandleUpload = React.useCallback(handleUpload);
return <UploadInput
onChange={boundHandleUpload}
accept="image/png,image/jpeg,image/gif,.jpg,.png,.jpeg,.gif"
/>
}
My upload input component
interface MyProps
extends React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> {
}
export const UploadInput = ({
onChange,
}: MyProps) => {
const inputRef = React.useRef<HTMLInputElement>(null);
return <input ref={inputRef as any} onChange={onChange} />
};
and my handleUpload function:
export const handleUpload = (evt: React.FormEvent<HTMLInputElement>): void => {
const target = evt.target as HTMLInputElement;
let file, img;
if ((file = target.files[0])) {
img = new Image();
img.onload = function () {
if(this.width > 1000) alert("Show Modal");
};
img.src = window.URL.createObjectURL(file);
}
};
How could I show a React component Modal instead of the alert?
Your function, even if external, must know about React state if you want it to work with React.
Just wrap your function handleUpload inside another function, to save setShowModal in the closure.
This way, you can edit the state of your component!
export const handleUpload = setShowModal => (evt: React.FormEvent<HTMLInputElement>): void => {
const target = evt.target as HTMLInputElement;
let file, img;
if ((file = target.files[0])) {
img = new Image();
img.onload = function () {
if(this.width > 1000) setShowModal(true);
};
img.src = window.URL.createObjectURL(file);
}
};
Now, handleUpload is a function that returns your event listener, so you just call it by passing setShowModal and it will return the same thing than before, but with the access to the state!
const MyParent = () => {
const [showModal, setShowModal] = useState(false);
const boundHandleUpload = React.useCallback(handleUpload(setShowModal));
return <>
<UploadInput
onChange={boundHandleUpload}
accept="image/png,image/jpeg,image/gif,.jpg,.png,.jpeg,.gif"
/>
{showModal && <Modal/>}
</>
}
Hope this is clear, feel free to ask if it is not!

How to ensure ref.current exists before firing on click function?

I am receiving an undefined error when trying to set canvasRef.current. I have tried many different ways to form a callback ref, but I am getting no luck. How can I wait to fire the onClick function 'handleViewStuff' AFTER canvasRef.current is not undefined?
const Child = (props) => {
const canvasRef = useRef();
const handleViewStuff = useCallback(() => {
apiCall(id)
.then((response) => {
// do stuff
return stuff;
})
.then((result) => {
result.getPage().then((page) => {
const canvas = canvasRef.current;
const context = canvas.getContext('2d'); // error is coming in here as getContext of undefined meaning canvas is undefined'
canvas.height = 650;
const renderContext = {
canvasContext: context,
};
page.render(renderContext);
});
});
}, []);
return (
<Fragment>
<canvas ref={(e) => {canvasRef.current = e}} />
<Button
onClick={handleViewStuff}
>
View Stuff
</Button>
</Fragment>
);
};
export default Child;
Using if-statement
...
if(canvas.current) {
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
}
Using optional chaining
...
const canvas = canvasRef?.current;
const context = canvas?.getContext('2d');
And I found some mistakes in your code.
add dependencies on useCallback
const handleViewStuff = useCallback(() => {
...
}, [canvasRef.current]);
should use ref like this.
<canvas ref={canvasRef} />

React: Saved state updates the DOM but not the console

When I click, I set the saveMouseDown state to 1, when I release I set it to 0.
When I click and move the mouse I log out mouseDown and it's 0 even when my mouse is down? Yet on the screen it shows 1
import React, { useEffect, useRef, useState } from 'react';
const Home: React.FC = () => {
const [mouseDown, saveMouseDown] = useState(0);
const [canvasWidth, saveCanvasWidth] = useState(window.innerWidth);
const [canvasHeight, saveCanvasHeight] = useState(window.innerHeight);
const canvasRef = useRef<HTMLCanvasElement>(null);
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D | null;
const addEventListeners = () => {
canvas.addEventListener('mousedown', (e) => { toggleMouseDown(); }, true);
canvas.addEventListener('mouseup', (e) => { toggleMouseUp(); }, true);
};
const toggleMouseDown = () => saveMouseDown(1);
const toggleMouseUp = () => saveMouseDown(0);
const printMouse = () => console.log(mouseDown);
// ^------ Why does this print the number 1 and the 2x 0 and then 1... and not just 1?
const removeEventListeners = () => {
canvas.removeEventListener('mousedown', toggleMouseDown);
canvas.removeEventListener('mouseup', toggleMouseUp);
};
useEffect(() => {
if (canvasRef.current) {
canvas = canvasRef.current;
ctx = canvas.getContext('2d');
addEventListeners();
}
return () => removeEventListeners();
}, []);
useEffect(() => {
if (canvasRef.current) {
canvas = canvasRef.current;
canvas.addEventListener('mousemove', (e) => { printMouse(); }, true );
}
return () => canvas.removeEventListener('mousemove', printMouse);
}, [mouseDown, printMouse]);
return (
<React.Fragment>
<p>Mouse Down: {mouseDown}</p>
{/* ^------ When this does print 1? */}
<canvas
id='canvas'
ref={canvasRef}
width={canvasWidth}
height={canvasHeight}
/>
</React.Fragment>
);
};
export { Home };
You only add the move listener once when the component mounted, thus enclosing the initial mouseDown value.
Try using a second useEffect hook to specifically set/update the onMouseMove event listener when the mouseDown state changes. The remove eventListener needs to specify the same callback.
useEffect(() => {
if (canvasRef.current) {
canvas = canvasRef.current;
canvas.addEventListener('mousemove', printMouse, true );
}
return () => canvas.removeEventListener('mousemove', printMouse);;
}, [mouseDown, printMouse]);
It may be simpler to attach the event listeners directly on the canvas element, then you don't need to worry about working with enclosed stale state as much with the effect hooks.
<canvas
onMouseDown={() => setMouseDown(1)}
onMouseUp={() => setMouseDown(0)}
onMouseMove={printMouse}
width={canvasWidth}
height={canvasHeight}
/>

Jest / Enzyme wait for the element to have a specific attribute

I'm creating a weather app with React and testing it with enzyme, on each page reload and on the initial page load the background image is supposed to change/load
App.js
function App() {
const [background, setBackground] = useState('');
async function fetchBgImage() {
// getRandomImage returns a link fetched from the api
setBackground(await getRandomImage());
}
useEffect(() => {
fetchBgImage(); //a method which sets the background image and sets background to image url
}, []);
return (
<div className="App">
// Here backgound is just an image url
<img id="background_image" src={background} alt="background image" />
</div>
);
}
In the real world the app works fine and the background image load properly and changes on each page reload, but when I'm testing with enzyme, it doesn't wait for the backgound property to be set and, consequently, the src property is empty.
App.test.js
beforeEach(() => {
// I read that useEffect doesnt work with shallow rendering
// https://github.com/airbnb/enzyme/issues/2086
jest.spyOn(React, 'useEffect').mockImplementation((f) => f());
});
it('displays background image', () => {
const wrapper = shallow(<App />);
const image = wrapper.find('#background_image');
// src prop is empty
console.log(image.props());
});
So how do I make enzyme wait for the src property of the image to be set?
You could write a helper function to wait for the attribute. Something like:
function waitForAttribute(el, attribute) {
return new Promise((resolve, reject) => {
const tryInterval = 100; // try every 100ms
let maxTries = 10; //try 10 times
let currentTry = 0;
const timer = setInterval(() => {
if (currentTry >= maxTries) {
clearInterval(timer);
return reject(new Error(`${attribute} not found on ${el.debug()}`));
}
const prop = el.props()[attribute]
if (prop) {
clearInterval(timer);
resolve(el);
}
currentTry++
}, tryInterval);
});
}
it('displays background image', async () => {
const wrapper = shallow(<App />);
const image = wrapper.find('#background_image');
const srcValue = await waitForAttribute(image, 'src')
// ...do something with it
});

Resources