React Expense Manager 2 - Writing Components and Tests
Let’s change our components slightly and…test them, obviously. Let’s do a bottom-up approach and continue what we did here . You’ll see that I deleted the Transaction.js component. What did I do instead? I created a Transactions.js component (think of it as a view component) where some of the logic for Transactions will be. That Transactions.js component imports a TransactionsTable.js component which actually displays the transactions that are passed down to it as props. So what is the TransactionsTable component?
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import Paper from "@material-ui/core/Paper";
const TransactionsTable = ({ transactions }) => {
// TODO: extract the fields (of interest) so that only certain
// fields are displayed in the table below
return (
<TableContainer component={Paper}>
<Table aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell align="right">Name</TableCell>
<TableCell align="right">Amount</TableCell>
<TableCell align="right">Currency</TableCell>
<TableCell align="right">Retailer</TableCell>
<TableCell align="right">Category</TableCell>
<TableCell align="right">Date</TableCell>
<TableCell align="right">Type</TableCell>
</TableRow>
</TableHead>
<TableBody>
{transactions.map((transaction) => (
<TableRow key={transaction.id}>
<TableCell component="th" scope="row">
{transaction.id}
</TableCell>
<TableCell align="right">{transaction.name}</TableCell>
<TableCell align="right">{transaction.amount}</TableCell>
<TableCell align="right">{transaction.currency}</TableCell>
<TableCell align="right">{transaction.retailer}</TableCell>
<TableCell align="right">{transaction.category}</TableCell>
<TableCell align="right">{transaction.date}</TableCell>
<TableCell align="right">{transaction.type}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
};
export default TransactionsTable;
So TransactionsTable exports a component called TransactionsTable that receives an array of transaction objects. I am using the Table component from Material UI to display the transactions. Nothing too exciting here. I plan to change some things that are currently hardcoded but for now this will do. Time to see our Transactions.js component:
import Container from "@material-ui/core/Container";
import TextFieldWithLabel from "./form_elements/controlled/TextField";
import { useStateOrLocalStorage } from "./hooks";
import TransactionsTable from "./TransactionsTable";
const Transactions = ({ transactions, loadingStatus, error }) => {
const [searchTerm, setSearchTerm] = useStateOrLocalStorage("search", "");
const filterTransactions = (event) => {
setSearchTerm(event.target.value);
};
const filteredTransactions = transactions.filter((transaction) =>
transaction.name.toLowerCase().includes(searchTerm)
);
return (
<>
<Container maxWidth="md">
<TextFieldWithLabel
id="transaction_search"
label="Search transactions"
value={searchTerm}
variant="outlined"
isFocused={false}
onChange={filterTransactions}
>
<strong>Search:</strong>
</TextFieldWithLabel>
{searchTerm.length > 0 && (
<p>The search term is currently: {searchTerm}</p>
)}
{error && <p>Where's the money Sonny? Please refresh.</p>}
{loadingStatus ? (
<p>Loading your money...</p>
) : (
<TransactionsTable transactions={filteredTransactions} />
)}
</Container>
</>
);
};
export default Transactions;
Ok for this one let’s discuss the imports first: Container, TextFieldWithLabel and useStateorLocalStorage (and TransactionsTable but that we’ve already seen). The Container is a component from Material-UI that is essentially a flexbox. The TextFieldWithLabel is a controlled text input form element created that we will reuse throughout the project. It’s in the form_elements/controlled folder. Let’s look at it.
import TextField from "@material-ui/core/TextField";
const TextFieldWithLabel = ({
id,
label,
variant,
value,
onChange,
children,
}) => {
return (
<div>
<label htmlFor={id}>{children}</label>
<TextField
id={id}
label={label}
value={value}
variant={variant}
onChange={onChange}
/>
</div>
);
};
export default TextFieldWithLabel;
Ok so as props it receives: id, label, variant (for the material-ui component TextField), an onChange function and any children. It is a controlled component meaning the form data is stored and handled in our component’s state. It doesn’t store any state in the actual component. Instead, we will pass it down from some higher component. Lastly, the actual Text Input field is a Material-UI component. Ok back to our Transactions.js component and the next import: a certain useStateOrLocalStorage hook. React has a few in-built hooks. Throughout this project we will use useState, useEffect and useReducer extensively (until we may or may not use Redux). This useStateOrLocalStorage hook is a custom hook I read in The Road to React and thought it’s pretty smart. So what does it do? Let’s check it out. It’s essentially a custom React hook. In the hooks.js file we have:
import { useState, useEffect } from "react";
export const useStateOrLocalStorage = (stateName, initialValue) => {
const [value, setValue] = useState(
localStorage.getItem(stateName) || initialValue
);
useEffect(() => {
localStorage.setItem(stateName, value);
}, [value, stateName]);
return [value, setValue];
};
First of all we import the useState and useEffect from ‘react’. The first one is a function that we use for our state: it returns a stateful value and the function that we can use to update it. Please note it’s written in such a way as to not tie it to anything in particular. We also import useEffect which is a function here that runs whenever the dependencies are changed. So what does the hook do? We pass it the name of a state variable and its initial value. In our Transactions.js component we pass it ‘search’ and ‘’. We want to have a search state variable with an initial value of ’’ (i.e. empty). Does that mean that if we pass in an initial value of ’’ that’s what will be displayed? Well, not necessarily. Notice the line
localStorage.getItem(stateName) || initialValue;
That’s what the useState hook uses for the initial state. It first checks if the state variable exists in localStorage. If it does, it gets that value. Otherwise, it uses the initialValue we passed to our custom hook. Why are we doing this? In case we refresh the page, the search term will still be there. What about the useEffect hook then? When either the value of our state changes, or the name for it changes, it sets the value of our state name in localStorage. Then the hook exports value, setValue which is what we use iin the Transactions.js component. It’s worth noting that it acts like the useState hook with that extra feature of writing/reading from localStorage.
Ok back to the transactions.js component. The searchTerm and setSearchTerm refer to what our custom hooks return. The searchTerm is the state responsible for keeping the value inside the search text field whereas setSearchTerm is the function used to update it. Then we have the following two functions:
const filterTransactions = (event) => {
setSearchTerm(event.target.value);
};
const filteredTransactions = transactions.filter((transaction) =>
transaction.name.toLowerCase().includes(searchTerm)
);
The first function takes in an event created whenever a form element changes and uses the setSearchTerm function to set the value of the searchTerm in our state. Then filteredTransactions simply goes through the transactions and filters out any that do not contain our searchTerm. Then the actual JSX return is:
<>
<Container maxWidth="md">
<TextFieldWithLabel
id="transaction_search"
label="Search transactions"
value={searchTerm}
variant="outlined"
isFocused={false}
onChange={filterTransactions}
>
<strong>Search:</strong>
</TextFieldWithLabel>
{searchTerm.length > 0 && <p>The search term is currently: {searchTerm}</p>}
{error && <p>Where's the money Sonny? Please refresh.</p>}
{loadingStatus ? (
<p>Loading your money...</p>
) : (
<TransactionsTable transactions={filteredTransactions} />
)}
</Container>
</>
I don’t need a wrapping element and to add extra nodes to the DOM so I am using the short syntax of React fragments to wrap everything in. Then again, I have a Material-UI container, this time with a maximum width, and in there I have the TextFieldWithLabel component to act as our Search/filter box. Note the various props that I am passing to it including searchTerm for its value and the filterTransactions function for the onChange handler. Then we have:
{
searchTerm.length > 0 && <p>The search term is currently: {searchTerm}</p>;
}
{
error && <p>Where's the money Sonny? Please refresh.</p>;
}
{
loadingStatus ? (
<p>Loading your money...</p>
) : (
<TransactionsTable transactions={filteredTransactions} />
);
}
What is that? I am using some conditional rendering to display the search term below the box if its not empty. Is that necessary? Not at the moment but I will use that at some point. Then, also using conditional rendering in case there were any errors during loading. Note that if you use the CONDITION && RETURN construct, the return will only occur if the first condition is True. Lastly, if everything loaded correctly, to display the Transactions table with the filteredTransactins array. Here I am using the ternary operator to either show a loading screen (well text) or a TransactionsTable. I am putting it there because soon we will actually be calling our API and so there might be a delay before anything is shown. Ok but where does error and loadingStatus come from? They were actually passed down as props into our Transactions component.
const Transactions = ({ transactions, loadingStatus, error }) = {...}
Ok, let’s go one step higher and look at our main App.js file.
import Transactions from './Transactions';
import { useEffect, useState } from 'react'
const initialTransactions = [
{
id: 1,
amount: 25.00,
name: "Rent",
retailer: "AWS",
category: "Housing and Utilities",
date: "2021-03-24 13:42:15",
currency: "GBP",
type: "expense",
recurring: false,
user: "Vlad"
},
{
id: 2,
amount: 50.00,
name: "Celery",
retailer: "Alphabet",
category: "Groceries",
date: "2021-03-23 13:42:15",
currency: "GBP",
type: "expense",
recurring: false,
user: "Vlad"
},
{
id: 3,
amount: 75.00,
name: "PI services",
retailer: "Nosebook",
category: "Work",
date: "2021-03-22 13:42:15",
currency: "GBP",
type: "expense",
recurring: false,
user: "Vlad"
},
{
id: 4,
amount: 150.00,
name: "Salary",
retailer: "Banana",
category: "Passive Income",
date: "2021-03-21 13:42:15",
currency: "GBP",
type: "income",
recurring: true,
user: "Vlad"
}
]
...
We import the Transactions component and the useEffect and useState React hooks. We still have that initialTransactions array of transaction objects for now (until we connect the frontend to the backend API). Nothing new for now. Then:
const App = () => {
const [transactions, setTransactions] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
useEffect(() => {
setIsLoading(true);
getAsyncTransactions()
.then((result) => {
setTransactions(result.data.transactions);
setIsLoading(false);
})
.catch(() => setIsError(true));
}, []);
return (
<Transactions
transactions={transactions}
loadingStatus={isLoading}
error={isError}
/>
);
};
In the App we use the useState hooks for our state (transactions, isLoading and isError) with initial values of empty array, true and false. Then, we use the useEffect hook with an empty dependency array -> that causes it to only run it when the component gets mounted. So when the App is mounted, the function is run. What happens in the function? It calls a certain getAsyncTransactions function (simulating our future API call) which is a Promise. If the Promise gets resolved, the setTransactions function is used to set the transactions state to the transactions that the Promise returns. It also sets the loading status to false, meaning the component has finished loading. If an error occurs, the error state is set to True. Those 3 state values are then passed down to the Transactions component as props. At the moment, still not use for Redux so no need to use that. Finally, let’s look at the getAsyncTransactions function:
const getAsyncTransactions = () =>
new Promise((resolve) =>
setTimeout(
() =>
resolve({
data: {
transactions: initialTransactions,
},
}),
200
)
);
It’s just a Promise that will resolve after 200ms. In its data.transactions, it stores the initialTransactions array.
And that’s what we have in our React app so far. You want more? Well, it wouldn’t be right if we didn’t test it. So let’s look at the tests we have. We have a test for our TransactionsTable component and one for the Transactions component. Let’s look at the table test first. In the src/tests directory, I have a TransactionsTable.test.js
file and also a sampletransactions.js file that I import from (essentially acting like a fixture that is reused for multiple tests).
import { render, screen } from "@testing-library/react";
import TransactionsTable from "../components/TransactionsTable";
import sampleTransactions from "./sampletransactions";
describe("TransactionsTable component", () => {
test("Tests that the transactions appear in the table.", () => {
const transactions = sampleTransactions;
render(<TransactionsTable transactions={transactions} />);
// check if you can see the initial transactions
const realTransactionRow = screen.getByText(/Housing/i);
expect(realTransactionRow).toBeInTheDocument();
const fakeTransactionRow = screen.queryByText(/MJ/i);
expect(fakeTransactionRow).not.toBeInTheDocument();
});
});
I am using React Testing Library to test our component. I import it, the component I am testing and the sampletransactions. I then render the component and make a few assertions. Since there is a Housing expense, it should be present in the document. MJ, however, shouldn’t be there. Nothing special about this test. What about the tests for the Transactions component? Slightly longer but hopefully equally as understandable. First, the imports
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import sampleTransactions from "./sampletransactions";
import Transactions from "../components/Transactions";
We get render, screen and also userEvent. (also I just had a major deja vu which would have been funnier if I was writing one of the posts on Web CNLearn using Vue..) userEvent is used to interact with the Search Input. We have three tests. Let’s go one by one.
test("Tests if while loading no transactions show.", () => {
const transactions = sampleTransactions;
const loadingStatus = true;
const error = null;
render(
<Transactions
transactions={transactions}
loadingStatus={loadingStatus}
error={error}
/>
);
// since the loading status is on, it should have the searchfield, the <p> with search term
// and the Loading your money... loadingStatus
const searchField = screen.getByLabelText("Search transactions");
expect(searchField).toBeInTheDocument();
const youBetterHaveMyMoney = screen.queryByText(/Loading your/i);
expect(youBetterHaveMyMoney).toBeInTheDocument();
const noTransactions = screen.queryByText(/Salary/i);
expect(noTransactions).not.toBeInTheDocument();
const searchTermContent = screen.queryByText(/The search term is currently/i);
expect(searchTermContent).not.toBeInTheDocument();
});
If the loadingStatus is true and there is no error, there shouldn’t be any transactions shown. Instead, only the Loading your money text should be visible. This test checks for that. The second test is mounting the Transactions component while passing error as true.
test("Tests if there is an error no transactions show.", () => {
const transactions = sampleTransactions;
const loadingStatus = true;
const error = true;
render(
<Transactions
transactions={transactions}
loadingStatus={loadingStatus}
error={error}
/>
);
// since there is an error, we should see <p>There has been a problem.
const wheresTheMoneySonny = screen.queryByText(/Where's the money Sonny\?/i);
expect(wheresTheMoneySonny).toBeInTheDocument();
});
Since there’s an error, we should see the error message: “Where’s my money Sonny?” (paraphrased slightly to avoid copyright infringement). And for the last test, let’s test what happens when someone types text into the search field.
test("Tests if typing in the search field causes the search term to update.", () => {
const transactions = sampleTransactions;
const loadingStatus = false;
const error = null;
render(
<Transactions
transactions={transactions}
loadingStatus={loadingStatus}
error={error}
/>
);
// the search field is currently empty
const searchInput = screen.getByLabelText("Search transactions");
expect(searchInput).toBeInTheDocument();
const searchTermContent = screen.queryByText(/The search term is currently/i);
expect(searchTermContent).not.toBeInTheDocument();
// there should be 3 expenses and 1 income
const expenses = screen.queryAllByText(/expense/i);
expect(expenses.length).toEqual(3);
const incomes = screen.queryAllByText(/income/);
expect(incomes.length).toEqual(1);
userEvent.type(searchInput, "hi");
const searchTermHi = screen.queryByText(/The search term is currently: hi/i);
expect(searchTermHi).toBeInTheDocument();
// check that no transactions are shown, i.e. 0 expenses, 0 incomes
const newExpenses = screen.queryAllByText(/expense/i);
expect(newExpenses.length).toEqual(0);
const newIncomes = screen.queryAllByText(/income/);
expect(newIncomes.length).toEqual(0);
});
Initially, we shouldn’t see “The search term is currently..” text and ther will be 3 expenses and 1 income transactions. Then, we use the userEvent to type into the text field. We write “hi”. No transactions should be shown then and that’s exactly what we see.
And there you have it! We wrote some (not the worst) React code, we tested it and it works. You can find the commit for this post here.