Frontend for the Fish Export Service (FES), using Remix (React-based web framework). This project supercedes https://dev.azure.com/defragovuk/DEFRA-MMO-FES/_git/mmo-fes-external-fe.
- mmo-fes-external-fe
- Things to Consider
mmo-cc-fe-v2
├── README.md
├── app
│ ├── .server/ - contains all the functions to interact with the server (orchestration and/or data-reader)
│ ├── components/ - contains all the base components of the app
│ ├── composite-components/ - they usually use a set of base components (non-atomic)
│ ├── controller/ - contains controller methods
│ ├── helpers/ - contains all the helper functions
│ ├── hooks/ - custom defined react hooks
│ ├── routes/ - contains all the routes of the app (\*)
│ ├── routes/ - contains all the routes of the app (\*)
│ ├── styles/ - contains all the styles of the app (\*\*)
│ ├── types/ - contains all the types in typescript
│ ├── entry.client.tsx
│ ├── entry.server.tsx
│ ├── root.tsx
│ └── routes
│ └── index.tsx
├── tests
│ ├── cypress/
│ │ └── integration/ - contains tests for components that have Remix server-side imports
│ │ └── fixtures/ - contains JSON files containing mock data
│ ├── msw/
│ │ └── handlers/ - contains handlers for returning mock data
│ ├── unit/ - contains tests for components that do not have any Remix server-side imports
├── package-lock.json
├── package.json
├── public
│ ├── assets/
│ │ └── fonts/
│ │ └── images/
│ ├── build/
│ ├── locales/
│ │ └── en/
│ │ └── cy/
│ └── favicon.ico
├── remix.config.js
├── remix.env.d.ts
└── tsconfig.json
Let's talk briefly about a few of these files:
app/- This is where all your Remix app code goesapp/entry.client.tsx- This is the first bit of your JavaScript that will run when the app loads in the browser. We use this file to hydrate our React components.- `app/entry.server.tsx - This is the first bit of your JavaScript that will run when a request hits your server. Remix handles loading all the necessary data and you're responsible for sending back the response. We'll use this file to render our React app to a string/stream and send that as our response to the client.
app/root.tsx- This is where we put the root component for our application. You render the element here.app/routes/- This is where all your "route modules" will go. Remix uses the files in this directory to create the URL routes for your app based on the name of the files.public/- This is where your static assets go (images/fonts/etc)remix.config.js- Remix has a handful of configuration options you can set in this file.
(*) The routes are the entry points of the app. Please use a flat structure when possible. Avoid to use index.tsx when possible (**) Most of the styles files in this folder are coming from "govuk-frontend" package and are the result of the
"sass": "sass --watch ./node_modules/govuk-frontend/govuk:app/styles"
Avoid creating more styles if they already present in the govuk project
The environment variables are used to configure the app.
touch .env
Add contents of .envSample to .env
example:
MMO_ECC_ORCHESTRATION_SVC_URL=http://localhost:5500
MMO_ECC_REFERENCE_SVC_URL=http://localhost:9000
mmo-cc-orchestration-svc git checkout develop git pull npm i npm run dev:without-auth
From your terminal:
npm run devThis starts your app in development mode, rebuilding assets on file changes.
First, build your app for production:
npm run buildThen run the app in production mode:
npm startNow you'll need to pick a host to deploy it to.
As Remix is designed to be rendered server side and as there is currently no official way of rendering components client side, for example with some sort of <Provider /> to wrap the component under test, the workaround for now is to use an e2e framework, which is where Cypress comes in.
For files or components that contain server-side imports from Remix, testing MUST BE done with Cypress. This requires running the app as testing will be done in the form of an e2e test, i.e., visiting a page headlessly or via a browser and then testing one or more components on that page. Backend APIs will be mocked using Mock Service Worker (msw) but the backend services can be left running while writing the tests to make it easier to get API payloads to use as mock data. See Testing steps for the details.
Cypress can also be used for regular unit tests when the code being tested DOES NOT have any imports from Remix.
- Add additional values to the
TestCaseIdenum underapp/types/tests.ts - Add mock data in the form of JSON files under
tests/cypress/fixtures - Create or update test handlers under
tests/msw/handlers. - Any new handlers must be imported into
tests/msw/handlers/index.ts - To enable API mocking, go to the
loaderoractionmethod of the page to be tested and add the following before any API calls:
/* istanbul ignore next */
setApiMock(request.url); // runs only when NODE_ENV === "test"- Add tests under
/tests/cypress/integrationfolder - Ensure the app is running using
npm run :test:start - In a second terminal, run all tests with
npm run :test:all - View coverage stats under
/coverage
To run an individual test spec:
- Ensure the app is running using
npm run :test:start - In a second terminal, run all tests with
npm run :test:spec path/to/test.spec.tsFor example:npm run :test:spec tests/cypress/integration/routes/catchCertificateDashboard.spec.ts
To run only one test in a file: You can use a .only
For example:
it.only('only run this one', () => { // similarly use it.skip(...) to skip a test })
it('not this one', () => { })
You can run Cypress tests in an isolated Docker environment using Docker Compose. This method:
- Eliminates the need to run
npm run :test:startmanually - Automatically builds the test image with all dependencies
- Creates an isolated network for testing
- Generates test results and coverage reports
Prerequisites:
- Docker and Docker Compose installed on your system
Running Tests with Docker:
From the project root directory:
docker compose -f tests/cypress/docker-compose.yml up --buildOr navigate to the tests directory:
cd tests/cypress
docker compose up --buildNote: The --build flag rebuilds the Docker image. If you haven't made any code changes since the last build, you can omit --build to speed up the startup process:
docker compose -f tests/cypress/docker-compose.yml upWhat it does:
- Builds the Docker image with the
testtarget from the Dockerfile - Creates a dedicated
cypressnetwork - Starts a frontend service container running the application
- Runs Cypress tests in a separate container against the frontend
- Outputs results to
./tests/cypress/test-results/ - Generates coverage reports in
./tests/cypress/coverage/
Optional Environment Variables:
You can customize the test run with environment variables:
# Run tests on a different port
export FRONTEND_PORT=3002
docker compose -f tests/cypress/docker-compose.yml up --build
# Run a specific test file
export TEST_PATH=./tests/cypress/integration/routes/specific-test.spec.ts
docker compose -f tests/cypress/docker-compose.yml up --build
# Run multiple specific test files (comma-separated)
export TEST_PATH="./tests/cypress/integration/routes/test1.spec.ts,./tests/cypress/integration/routes/test2.spec.ts"
docker compose -f tests/cypress/docker-compose.yml up --build
# Run all tests in a specific folder
export TEST_PATH="./tests/cypress/integration/routes/**/*.spec.ts"
docker compose -f tests/cypress/docker-compose.yml up --build
# Change the hostname
export FRONTEND_HOSTNAME=frontend-test
docker compose -f tests/cypress/docker-compose.yml up --buildClean up:
After testing, remove the containers and network:
docker compose -f tests/cypress/docker-compose.yml downBenefits:
- Consistent test environment across different machines
- No need to manage Node.js versions or dependencies locally
- Easy integration with CI/CD pipelines
- Isolated testing environment prevents conflicts with local setup
- The command
npm run :test:startcreates a production build as msw doesn't work properly in watch mode with a development build. For every code change, terminate and run the app again withnpm run :test:startto rebuild the code - We are testing page components (files under
app/routes) and so we are not targeting specific composite components; all components on the page will be tested as part of the e2e test run - All API calls will have to be mocked to return mock responses. This is for all API calls across the entire journey for the test. If a test starts on one page and ends on another page, API calls in the loader of the other page will also need to be mocked (we could mock the API calls in the action method of the other page as well but ideally that should be a separate test). The tests should not hit the real API. Full e2e testing will be carried out by the QA team. As frontend developers, we should only work with mock data in our tests. Instead of using mock data for components props as we would do with React testing library, with our e2e tests we are now mocking the API responses and testing if the page and its constituent components are behaving as expected
Test cases: Start with the loader and action methods of the page to be tested and for any page(s) the test will land on and check for all API calls. Think about the different scenarios requiring different mock responses from the API calls; then under app/types/tests.ts add additional values to the TestCaseId enum to name each scenario.
For example, the app/routes/create-catch-certificate/$documentNumber/progress.tsx page makes three different API calls and these are the test cases for testing the Progress page with different landing types for catch certificates:
export enum TestCaseId {
...
...
CCUploadEntryIncompleteProgress = "ccUploadEntryIncompleteProgress",
CCUploadEntryCompleteProgress = "ccUploadEntryCompleteProgress",
CCDirectLandingCompleteProgress = "ccDirectLandingCompleteProgress",
CCManualEntryCompleteProgress = "ccManualEntryCompleteProgress",
...
...
}The values themselves don't matter as long as they are unique and descriptive. These values will be used as query-string parameters during the test run. For example, Cypress will make a request to a page like so:
http://localhost:3000/create-catch-certificate/GBR-2022-CC-488FE89C1/progress?testCaseId=ccUploadEntryProgressData
The presence of the testCaseId query-string parameter will inform msw which test handler to use to mock API responses.
Mock data: Having checked how many API calls are being made in the journey to be tested, create mock data in the form of JSON files and store them under tests/cypress/fixtures. The folders within are based on the service URLs listed in app/helpers/urls.ts. For each API, add as many varied responses as necessary.
Handlers: In order to mock all of the API calls in the page to be tested, a new test handler needs to be added. As an example, here is an excerpt from tests/msw/handlers/progressPageHandler.ts:
...
import uploadEntryLandingsType from "@/fixtures/landingsTypeApi/uploadEntry.json";
...
...
const progressPageHandler: ITestHandler = {
[TestCaseId.CCUploadEntryIncompleteProgress]: () => [
rest.get(LANDINGS_TYPE_URL, (req, res, ctx) => res(ctx.json(uploadEntryLandingsType))),
rest.get(getProgressUrl("catchCertificate"), (req, res, ctx) => res(ctx.json(progressIncomplete))),
rest.get(getTransportDetailsUrl("catchCertificate"), (req, res, ctx) => res(ctx.json(truckTransportDetails))),
],
...
...
};Note that the keys in the test handler use TestCaseId, which was updated in the previous step. The mock function must return an array. As the Progress page makes three API calls, the array above contains three corresponding statements to return mock data. The package being used for API mocking is called Mock Service Worker or msw. Please see official documentation for details.
As can be seen above, JSON files are imported into the hander for the page. The handler functions will be matched by TestCaseId and msw will return mock data from the JSON files.
After adding a new handler, update:
import type { ITestHandler } from "~/types";
import indexPageHandler from "./indexPageHandler";
+ import progressPageHandler from "./progressPageHandler";
const rootTestHandler: ITestHandler = {
...indexPageHandler,
+ ...progressPageHandler,
};
export default rootTestHandler;Enable API mocking: In the page to be tested add the following inside a loader function as needed. Ensure this is done before any statements that make API calls. Note this should only be added to loaders and not actions, but the msw handler should be set up to cover API calls made in the action. There are plenty of examples of this in the codebase. The tests/cypress/integration/routes/addReference.spec.ts spec is one such example.
/* istanbul ignore next */
setApiMock(request.url); // runs only when NODE_ENV === "test"This statement enables API mocking and is annotated with /* istanbul ignore next */ to prevent the line from being included in stats for code coverage.
The setApiMock function will look at the value of the query-string testCaseId from the request URL to lookup the corresponding handler function and return mock data.
Apart from the query-string testCaseId, we can also pass an optional query-string called args, which is an array of values that can be used to make decisions within the handler as to what mock data to return. As an example, please see tests/cypress/integration/routes/index.spec.ts and app/routes/index.tsx. The corresponding handler tests/msw/handlers/indexPageHandler.ts looks like this:
...
[TestCaseId.StartJourney]: (journey: Journey) => {
if (journey === "catchCertificate") {
return [
rest.post(SAVE_AND_VALIDATE_URL, (req, res, ctx) => res(ctx.json(ccJourney))),
rest.get(mockGetAllDocumentsUrl, (req, res, ctx) => res(ctx.json(ccDrafts))),
rest.get(NOTIFICATION_URL, (req, res, ctx) => res(ctx.status(204))),
];
}
if (journey === "storageNotes") {
...
...So the value of journeySelection passed in the args query-string helps the handler function decide on different mock responses.
Tests: Add tests under /tests/cypress/integration. Please see tests/cypress/integration/routes/ccProgress.spec.ts as an example. The main thing to ensure is that the tests add ?testCaseId= to the request URL in order to get mock data from loader functions. For action functions, use the method described above as Remix strips out query-string parameters when posting a form.
...
const testParams: ITestParams = {
testCaseId: TestCaseId.CCUploadEntryCompleteProgress,
};
cy.visit(progressUrl, { qs: { ...testParams } });
...
...Running tests: Before running tests, ensure the app has been started with npm run :test:start. If not, the app will make real API calls. Also, stats for code coverage will not be generated.
After starting the app with npm run :test:start, Cypress tests can be run.
- For command-line output:
npm run :test:all - Alternately, for the Cypress GUI:
npm run cy:openbut this will not generate code coverage and is more ideally suited for use during development and writing of tests - Stats for code coverage are generated if the app was started with
npm run :test:startfollowed bynpm run :test:all - Code coverage can be viewed under
/coverage
Note that if we forget to mock API calls, msw will give us a warning in the server console. For example,
[MSW] Warning: captured a request without a matching request handler:
• GET http://localhost:5500/v1/notification
If you still wish to intercept this unhandled request, please create a request handler for it.
In this case, the API call will be forwarded on to the backend and the test may or may not pass. However, as already mentioned, all API calls must be mocked so whenever the above warning is seen, ensure that the handler function is updated to intercept the API call and return mock data.
As Remix is primarily built around progressive enhancement with JavaScript, it is good practice to write tests that test a page with JavaScript disabled. Here is a sample test:
it("should render conditional input when JavaScript is disabled", () => {
const testParams: ITestParams = {
testCaseId: TestCaseId.WhoseWatersNull,
disableScripts: true,
};
cy.visit(WhoseWaterUrl, { qs: { ...testParams } });
cy.findByRole("textbox").should("exist");
});
All that is required is to pass an additional query-string parameter called disableScripts and set it to true. While this does not disable JavaScript in the browser, it prevents the client-side script bundles from loading and ensures the app is completely server-side rendered. This is equivalent to disabling JavaScript.
- In MSW handlers, always prefer
resoverres.onceas thetestCaseIdis lost from the URL when tests involve form submissions or navigation to other pages; althoughres.oncewill work in most cases, it is easier to just stick toresto ensure the API mocks persist for the duration of the test - Where tests perform navigation, ensure API calls in the
loaderof the destination page are also mocked - When developing locally, a good way to ensure MSW mocks have been set up properly is by shutting down the orchestration service and then running tests. Watch for occurrences of
[MSW] Errorin the server console and add any missing mocks. - MSW ignores query-string parameters in API URLs. Whenever there is an API URL containing query-string parameters, create an alternate URL specifically for MSW. For example, instead of using
searchVesselNamefromapp/helpers/urls.ts, usemockSearchVesselNameinstead (also defined in the same file). Note that the query-string part of the URL has been removed. - Similarly, where the dynamic part of the API URL does not matter, use a mock URL containing placeholder. For example, in the MSW handlers use
mockGetAllDocumentsUrlinstead ofgetAllDocumentsUrl. It has placeholders:yearand:monthand this URL can be used in MSW handlers in situations where the values for year and month are not important to the test.
- Ensure the app has been started with
npm run :test:start - To run all tests and to generate stats for code coverage:
npm run :test:all - View stats for code coverage in the
coveragefolder
Generating stats for code coverage relies on the code being instrumented. The :test:start npm script has a pre script that generates instrumented code and puts it in the instrumented folder. Remix then uses the instrumented folder to build the app.
The stats for code coverage are stored in the coverage folder.
The :test:all npm script will run all tests and will generate aggregated stats for code coverage.
Run the following:
npm run :test:start
npm run :test:ci- This repository should use GitFlow as a branching strategy.

- If you won't call your branch as per agreed branching
standards, the Azure pipeline won't start or may fail to deploy an image.
In Remix, data is typically loaded by the loader function that executes server side. Here's more from Sergio Xalambrí, a popular contributor on Remix forums, on how the loader function works:
Because the fetch happens outside Remix loaders, you need to fetch server side to provide data to your context for the SSR and then fetch again (or somehow share the data) client side to provide the same data again. This is another reason why using a loader is better, you don’t need to care about this.
...Remix doesn’t fetch a loader or already has data for when doing a navigation, only after a form submission, the root is also not only used for / but for every route. Again it will be called in the first request of the app and then after a form submission
The render happens at any time, that’s React, but the fetch doesn’t,
useLoaderDatais not running the loader just accessing the data
Please see the entry here on stackoverflow for more context.
If data is required to be refreshed client side, we can use the useDataRefresh hook from remix-utils like so:
import { useDataRefresh } from "remix-utils/useDataRefresh";
let { refresh } = useDataRefresh();
// The refresh function can then be invoked anywhere, for example, in useEffect
useEffect(() => refresh(), [refresh]);Normally, the only time the loader function gets called again after the initial render is if the page has an action method and a form submission occurs. For pages that do not have an action method or if we would like to refresh data without explicitly submitting a form we rely on this special dummy file app/routes/dev/null.ts and the action method there just returns null. Upon refresh, the loader function will be invoked again.
- https://remix.run/docs/en/v1/api/conventions
- https://remix.run/docs/en/v1/api/remix
- https://remix.run/docs/en/v1/guides/data-loading
- https://remix.run/docs/en/v1/guides/data-writes
- https://remix.run/docs/en/v1/guides/routing#dynamic-segments
- https://remix.run/docs/en/v1/guides/styling
- https://github.com/sergiodxa/remix-utils