Showing posts with label VueJs/Symfony/Docker/TDD series. Show all posts
Showing posts with label VueJs/Symfony/Docker/TDD series. Show all posts

Saturday 6 February 2021

Part 9: I mess up how I configure my Docker containers

G'day:

This is an article I am inserting into my ongoing series of setting up a web app using Docker containers, running Nginx, PHP (and Symfony within that), MariaDB, with a Vue.js front end being built in a Node.js container. The other articles can be found here:

  1. Intro / Nginx
  2. PHP
  3. PHPUnit
  4. Tweaks I made to my Bash environment in my Docker containers
  5. MariaDB
  6. Installing Symfony
  7. Using Symfony
  8. Testing a simple web page built with Vue.js using Mocha, Chai and Puppeteer
  9. I mess up how I configure my Docker containers (this article)
  10. An article about moving files and changing configuration
  11. Setting up a Vue.js project and integrating some existing code into it
  12. Unit testing Vue.js components

A week or so ago I wrote an article "Part 8: Testing a simple web page built with Vue.js using Mocha, Chai and Puppeteer". In there I detail how I had configured my Node.js container in Dockerfile and docker-compose.yml, and how it all worked nicely. A few days ago when prepping for the last article of that series ("Refactoring the simple web page into Vue components" - not written yet, so no link yet either, sorry), I discovered my Node.js container was only working by a coincidental quirk of how I built it: and as it stood, the configuration I cited didn't actually work as I described it. I had made a rather school-boy-ish error.

I'm going to explain what I did wrong using a stand-along Node.js container / app, because it'll be a bit more clear if I just focus on the one container, not the array of moving parts I have in that other application. Then I show how I applied a working solution to the that main app.

The requirement I am fulfilling here is to set up a simple Docker container running Node.js, and run some Mocha tests for a code puzzle I have. Normally-speaking one would not have both a Dockerfile and a docker-compose.yml file for this sort of minimal requirement, but the issue I had was with how Dockerfile and docker-compose.yml interact, so I'll include both.

Firstly my Dockerfile:

FROM node
WORKDIR  /usr/share/nodeJs/

This just grabs the Node.js image from Docker, sets the directory I'm gonna be using for my code. nothing exciting. In the docker-compose.yml file I've got this lot:

version: '3'

services:
  node:
    build:
      context: node
    volumes:
      - ..:/usr/share/nodeJs
      - ./node/root_home:/root
    stdin_open: true
    tty: true

This is all straight-forward seeming. So as I can develop my source code files as test as I go, I have mapped my host's nodeJs directory to the container's working directory /usr/share/nodeJs. In addition to that I'm mapping another directory I have in my project as the container's root user's home directory. This is so I can a) avail the container of a .bashrc file I've got some aliases in; b) the .bash_history will be stored on my host machine, so it will persist between rebuilds of the container. This stuff with the root_home stuff is not important to this article.

I can build this container and run it and dive inside:

adam@DESKTOP-QV1A45U:/mnt/c/src/nodejs/docker$ docker-compose up --build --detach
Building node
Step 1/2 : FROM node
latest: Pulling from library/node
Digest: sha256:7543db0284b51d9bff8226cf8098f15fdf0a9ee6c4ca9bc0df4f11fa0e69e09d
Status: Downloaded newer image for node:latest
---> ea27efc47a35
Step 2/2 : WORKDIR /usr/share/nodeJs/
---> Using cache
---> 97812d1f94c8

Successfully built 97812d1f94c8
Successfully tagged nodejs_node:latest
Creating nodejs_node_1 ... done


adam@DESKTOP-QV1A45U:/mnt/c/src/nodejs/docker$ docker exec --interactive --tty nodejs_node_1 /bin/bash
root@b01ee364c0cc:/usr/share/nodeJs# npm install

[...]

added 113 packages, and audited 114 packages in 14s

[...]

found 0 vulnerabilities
root@b01ee364c0cc:/usr/share/nodeJs#


root@b01ee364c0cc:/usr/share/nodeJs# node_modules/.bin/mocha test/**/*Test.js --reporter min
  36 passing (212ms)

root@f112a78fde91:/usr/share/nodeJs#

Here I am doing the following:

  • Building the container from Dockerfile and docker-compose.yml config;
  • shelling into it;
  • Remembering I need to actually install the npm modules;
  • Running some unit tests to demonstrate the npm stuff all installed OK, and the code is operational.

Oh, for good measure, npm install is installing this lot (from package.json):

  "devDependencies": {
    "chai": "^4.2.0",
    "chai-as-promised": "^7.1.1",
    "chai-datetime": "^1.7.0",
    "chai-match": "^1.1.1",
    "chai-string": "^1.5.0",
    "deep-eql": "^3.0.1",
    "mocha": "^8.2.1"
  },

So if everything had not worked A-OK, the tests would not have run, let alone passed.

The process for the app I'm writing in the blog article series is the same as this, just with a bunch more stuff in the docker-compose.yml file. And it all worked the same as above, so I'm happy a Larry, and off I go to write my blog article.

When I come to start prepping for the next article, I remember I had to do the npm install step manually, and that was wrong, so I went to move it into the Dockerfile. At the same time the next article called for me to install Vue CLI, so I chucked that into the Dockerfile too:

FROM node
WORKDIR /usr/share/nodeJs/
RUN npm install -g @vue/cli
RUN npm install

Because I wanted to test this from scratch, I removed the package-lock.json file and node-modules directory from the previous install, and completely rebuilt the container:

adam@DESKTOP-QV1A45U:/mnt/c/src/nodejs/docker$ docker-compose down --remove-orphans && docker-compose up --build --detach
Removing nodejs_node_1 ... done
Removing network nodejs_default
Creating network "nodejs_default" with the default driver
Building node
Step 1/4 : FROM node
---> ea27efc47a35
Step 2/4 : WORKDIR /usr/share/nodeJs/
---> Using cache
---> 97812d1f94c8
Step 3/4 : RUN npm install -g @vue/cli
---> Running in 8a33bda430b9

[...]

added 1398 packages, and audited 1399 packages in 4m

[...]

found 0 vulnerabilities

[...]

Removing intermediate container 8a33bda430b9
---> f0752a8149f4
Step 4/4 : RUN npm install
---> Running in df849deb15ad

up to date, audited 1 package in 178ms

found 0 vulnerabilities
Removing intermediate container df849deb15ad
---> 5445fb6c455a

Successfully built 5445fb6c455a
Successfully tagged nodejs_node:latest
Creating nodejs_node_1 ... done
adam@DESKTOP-QV1A45U:/mnt/c/src/nodejs/docker$

That all seems fine: I can see the two npm install runs happening. So I proceed into the container:

adam@DESKTOP-QV1A45U:/mnt/c/src/nodejs/docker$ docker exec --interactive --tty nodejs_node_1 /bin/bash
root@691991a95337:/usr/share/nodeJs# vue --version
@vue/cli 4.5.11

So far… so good. Vue CLI is installed and running. And just to make sure the tests still run:

root@691991a95337:/usr/share/nodeJs# ls
LICENSE  README.md  docker  package.json  src  test
root@691991a95337:/usr/share/nodeJs#

Ummm… what? No node_modules, and no package-lock.json.

but the Vue CLI stuff is working, and all there OK:

root@691991a95337:/usr/share/nodeJs# npm root -g
/usr/local/lib/node_modules
root@691991a95337:/usr/share/nodeJs# ls /usr/local/lib/node_modules
@vue  npm
root@691991a95337:/usr/share/nodeJs#

I googled all over the place for someone / something to blame for this. I googled for npm install silectly failing. I removed the Vue CLI stuff and the npm install seemed to run, but did nothing. I noticed the Removing intermediate container df849deb15ad, and surmised Docker was doing the second npm install in a temporary container for some reason, and then dropping it so… no more node_modules or package-lock.json in my working directory. So I googled about the place to find ways to stop bloody Docker putting things into intermediate containers (without thinking to google why it might be doing that in the first place).

And this is something that really annoys me about myself sometimes. I also see it in other devs (and I've mentioned it here before, too). My first thought when something is going wrong is that it's someone else's fault. Someone else has buggered something up in Docker, Node, Vue, etc; and I'm just being caught out by it. This is so hubristic of me given this is my first time working with this stuff, and millions of other people get on just fine with it. My first thought should always be: what have I done wrong?

Then it dawned on me. Well actually someone said something in a Stack Overflow answer which was unrelated to this, but got me thinking. Here is what happens:

We have this in the Dockerfile:

WORKDIR /usr/share/nodeJs/
[...]
RUN npm install

What does this do? It created a directory /usr/share/nodeJs/ and then runs npm install. What's in the directory at that point? F*** all. Certainly not a package.json file. That doesn't come into play until the container is created, and we're mapping volumes into it, when DOcker looks at the docker-compose.yml stuff, which isn't until after the Dockerfile processing is done.

OK so I'll need the package.json file around in the Dockerfile. First I just try to copy it from the root of my app directory (where it currently is), and this yields and error when I try to build the container:

Step 3/5 : COPY ../../package.json .
ERROR: Service 'node' failed to build : COPY failed: forbidden path outside the build context: ../../package.json ()

Fair enough. it occurs to me I only need it in the root of the app in the container; as I'm not running any ofthis stuff on my host machine, it does not need to be in the root of the application there at all. It can be anywhere. So I shift it into a config subdirectory, within my docker subdirectory:

adam@DESKTOP-QV1A45U:/mnt/c/src/nodejs/docker$ tree -aF --dirsfirst -L 2 node
node
├── config/
│   └── package.json*
├── root_home/
│   └── [...]
└── Dockerfile*

This time I can see it installing all the dependencies fine, but it's still in a intermediate container:

Step 5/5 : RUN npm install
---> Running in caeaa9d3b75c

added 113 packages, and audited 114 packages in 5s

16 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
Removing intermediate container caeaa9d3b75c
---> 3b0c15a86641

Successfully built 3b0c15a86641
Successfully tagged nodejs_node:latest
Creating nodejs_node_1 ... done

Unsurprisingly that being the case, the node_modules directory is not there when I check in the container file system. But also…neither is the package.json file! All that's in there is the contents of … [the penny has just dropped] … my host machine's application directory:

root@6e2d1c5d482f:/usr/share/nodeJs# ls
LICENSE  README.md  docker  src  test
root@6e2d1c5d482f:/usr/share/nodeJs# exit
exit

adam@DESKTOP-QV1A45U:/mnt/c/src/nodejs/docker$ ls ..
LICENSE  README.md  docker  src  test
adam@DESKTOP-QV1A45U:/mnt/c/src/nodejs/docker$

Now. Do you think that might be because that's exactly what I'm telling docker-compose.yml to do? Here:

volumes:
  - ..:/usr/share/nodeJs

I'm telling docker-compose to plaster that directory from my host machine over the top of the directory that Dockerfile has been working in.

Sigh.

And this makes total sense too.

Right. I have some reorganising to do. The problem really is that I'm voluming-in (to invent a phrase) the entire app directory into the container's working directory - which I know now will not work - I have to think about what I need in the working directory for the app to run:

  • I need the package.json file in there at the time Dockerfile is processed, because that's when npm install happens. Cool I've already done that.
  • After I've done a successful npm install I should also grab the package-lock.json file and make sure I also stick it in the working directory in the Dockerfile. But I'll deal with that later after everything is working.
  • If we have a look at what else is in that root application directory:
    camer@DESKTOP-QV1A45U MINGW64 /c/src/nodejs ((1.0.4))
    $ ll
    total 125
    drwxr-xr-x 1 camer 197609     0 Feb  6 10:14 docker/
    -rw-r--r-- 1 camer 197609 35149 Jan 29 14:47 LICENSE
    -rw-r--r-- 1 camer 197609   680 Feb  6 10:14 package.json
    -rw-r--r-- 1 camer 197609 79915 Feb  6 10:14 package-lock.json
    -rw-r--r-- 1 camer 197609    30 Jan 29 14:47 README.md
    drwxr-xr-x 1 camer 197609     0 Feb  1 12:37 src/
    drwxr-xr-x 1 camer 197609     0 Feb  1 23:30 test/

    camer@DESKTOP-QV1A45U MINGW64 /c/src/nodejs ((1.0.4))
    All we really need in the container is the src/ and test/ directories. We don't need to add the whole directory as a volume: just those two will do. Also I'm thinking ahead of my other app here, which has a container for PHP as well, and in that there's a bunch of stuff that lives in the app root directory for Symfony, PHPUnit, PHPMD, PHPCS etc. Currently messed in along with the Node.js stuff. That's a bit of a mess, and when those files are being used, they're never all in a given container: they're used in different ones. So now that I think about it, it's "wrong" that they are all in the same directory in source control. But I'm getting ahead of myself. Back to this simple Node.js container situation.

So the solution to this seems to be:

adam@DESKTOP-QV1A45U:/mnt/c/src/nodejs$ tree -F --dirsfirst docker
docker
├── node/
│   ├── config/
│   │   ├── package-lock.json*
│   │   └── package.json*
│   └── Dockerfile*
└── docker-compose.yml*

2 directories, 4 files
adam@DESKTOP-QV1A45U:/mnt/c/src/nodejs$
FROM node
WORKDIR  /usr/share/nodeJs/
COPY config/* ./
RUN npm install

As I said earlier, I move the config stuff that Dockerfile needs into a subdirectory of the docker config.

Next in the application root directory, I have no container-specific stuff, just the docker, src and test directories; and the source control project bumpf:

adam@DESKTOP-QV1A45U:/mnt/c/src/nodejs$ tree -F --dirsfirst -L 1
.
├── docker/
├── src/
├── test/
├── LICENSE*
└── README.md*

3 directories, 2 files
adam@DESKTOP-QV1A45U:/mnt/c/src/nodejs$

In docker-compose.yml we specifically make volumes for just the src/ and test/ directories:

version: '3'

services:
  node:
    build:
      context: node
    volumes:
      - ./node/root_home:/root
      - ../src:/usr/share/nodeJs/src
      - ../test:/usr/share/nodeJs/test
    stdin_open: true
    tty: true

This means everything is in the right place when it's needed, and there's no overlap in any of the file system stuff in Dockerfile and docker-compose.yml. And when I now build the container from scratch again…

adam@DESKTOP-QV1A45U:/mnt/c/src/nodejs/docker$ docker-compose up --build --detach
Creating network "nodejs_default" with the default driver
Building node
Step 1/4 : FROM node
[...]
Step 2/4 : WORKDIR  /usr/share/nodeJs/
---> Running in 6790771b61ba
Removing intermediate container 6790771b61ba
[...]
Step 3/4 : COPY config/* ./
[...]
Step 4/4 : RUN npm install
[...]
Removing intermediate container de1de62c87e8

Successfully built c7d34c963f25
Successfully tagged nodejs_node:latest
Creating nodejs_node_1 ... done


adam@DESKTOP-QV1A45U:/mnt/c/src/nodejs/docker$ docker exec --interactive --tty nodejs_node_1 /bin/bash
root@1b011f8852b1:/usr/share/nodeJs# ll
total 100
drwxr-xr-x  1 root root  4096 Feb  6 11:05 ./
drwxr-xr-x  1 root root  4096 Feb  6 11:05 ../
drwxr-xr-x 97 root root  4096 Feb  6 11:05 node_modules/
-rwxrwxrwx  1 root root 79915 Feb  6 11:05 package-lock.json*
-rwxrwxrwx  1 root root   680 Feb  6 11:05 package.json*
drwxrwxrwx  1 node node   512 Feb  6 10:52 src/
drwxrwxrwx  1 node node   512 Feb  6 10:52 test/


root@1b011f8852b1:/usr/share/nodeJs# ./node_modules/.bin/mocha test/**/*Test.js --reporter=min

  36 passing (212ms)


root@1b011f8852b1:/usr/share/nodeJs#

… it's all good.

I note with interest that the "Removing intermediate container" thing was a red herring in all this: it happens as a matter of course anyhow. The good news is that everything is there and working fine.

That's about all I have to say on that. I have already made equivalent changes to the codebase for the code for the long-running article series I'm writing. I'll summarise that in the next article.

It's just worth reflecting on this again though:

And this is something that really annoys me about myself sometimes. [...] My first thought when something is going wrong is that it's someone else's fault[...] and I'm just being caught out by it. This is so hubristic of me given this is my first time working with this stuff, and millions of other people get on just fine with it. My first thought should always be: what have I done wrong?

Righto.

--
Adam

Thursday 21 January 2021

Listening to the console log of a page loaded with Puppeteer

G'day:

This is a follow-up from something I touched on yesterday ("Polishing my Vue / Puppeteer / Mocha / Chai testing some more"). In that exercise I was using Puppeteer to load a web page I was testing, and then pulling some DOM element values out and checking they matched expectations. The relevant bits of code are thus:

describe.only("Tests of githubProfiles page using github data", function () {
    let browser;
    let page;
    let expectedUserData;

    before("Load the page and test data", async function () {
        await loadTestPage();
        expectedUserData = await loadTestUserFromGithub();
    });

    let loadTestPage = async function () {
        browser = await puppeteer.launch( {args: ["--no-sandbox"]});
        page = await browser.newPage();

        await Promise.all([
            page.goto("http://webserver.backend/githubProfiles.html"),
            page.waitForNavigation()
        ]);
    }

    it("should have the expected person's name", async function () {
        let name = await page.$eval("#app>.card>.content>a.header", headerElement => headerElement.innerText);
        name.should.equal(expectedUserData.name);
    });

  • Load the page with Puppeteer
  • Example test checking the page's DOM

This code seemed to be running fine, and the tests were passing. As I was adding more code ot my Vue component on the client end, I suddenly found the tests started to fail. Sometimes. If I ran them ten times, they'd fail maybe three times. At the same time, if I was just hitting the page in the browser, it was working 100% of the time. Odd. I mean clearly I was doing something wrong, and I'm new to all this async code I'm using, so figured I was using values before they were available or something. But it seemed odd that this was only manifesting sometimes. The way the tests were failing was telling though:

1) Tests of githubProfiles page using github data
       should have the expected person's name:

      AssertionError: expected '' to equal 'Alex Kyriakidis'
      + expected - actual

      +Alex Kyriakidis

The values coming from the DOM were blank. And note that it's not a case of the DOM being wrong, because if that was the case, the tests would barf all the time, with something like this:

Error: Error: failed to find element matching selector "#app>.card>.content>a.header"

The relevant mark-up here is:

<a class="header" :href="pageUrl">{{name}}</a>

So {{name}} isn't getting its value sometimes.

I faffed around for a bit reading up on Vue components, and their lifecycle handlers in case created was not the right place to load the data or something like that, but the code seemed legit.

My JS debugging is not very sophisticated, and it's basically a matter of console.logging stuff and see what happens. I chucked a bunch of log calls in to see what happens:

created () {
    console.debug(`before get call [${this.username}]`);
    axios.get(
        `https://api.github.com/users/${this.username}`,
        {
            auth: {
                username: this.$route.query.GITHUB_PERSONAL_ACCESS_TOKEN
            }
        }
    )
    .then(response => {
        console.debug(`beginning of then [${response.data.name}]`);
        this.name = response.data.name;
		// [etc...]
        console.debug("end of then");
    });
    console.debug("after get call");
}

Along with some other ones around the place, these all did what I expected when I hit the page in the browser:

beginning of js
githubProfiles.js:46 before VueRouter
githubProfiles.js:52 before Vue
githubProfiles.js:23 before get call [hootlex]
githubProfiles.js:43 after get call
githubProfiles.js:63 end of js
githubProfiles.js:33 beginning of then [Alex Kyriakidis]
githubProfiles.js:41 end of then

I noted that the then call was being fulfilled after the mainline code had finished, but in my test I was waiting for the page to fully load, so I'd catered for this. Repeated from above:

await Promise.all([
    page.goto("http://webserver.backend/githubProfiles.html"),
    page.waitForNavigation()
]);

I ran my tests, and was not seeing anything in the console which momentarily bemused me. But then I was just "errr… duh, Cameron. That stuff is logging in the web page's console. Not Node's console from the test run". I'm really thick sometimes.

This flumoxed me for a bit as I wondered how the hell I was going to get telemetry out of the page that I was calling in the Puppeteer headless browser. Then it occurred to me that I would not be the first person to wonder this, so just RTFMed.

It's really easy! The Puppeteer Page object exposes event listeners one can hook into, and one of the events is console. Perfect. All I needed to do is put this into my test code:

page = await browser.newPage();

page.on("console", (log) => console.debug(`Log from client: [${log.text()}] `));

await Promise.all([
    page.goto("http://webserver.backend/githubProfiles.html"),
    page.waitForNavigation()
]);

Then when I ran my tests, I was console-logging the log entries made in the headless browser as they occurred. What I was seeing is:

  Tests of githubProfiles page using github data
Log from client: [beginning of js]
Log from client: [before VueRouter]
Log from client: [before Vue]
Log from client: [before get call [hootlex]]
Log from client: [after get call]
Log from client: [end of js]
    1) should have the expected person's name
    2) should have the expected person's github page URL
    3) should have the expected person's avatar
    4) should have the expected person's joining year
    5) should have the expected person's description
Log from client: [beginning of xxxxxx then [Alex Kyriakidis]]
Log from client: [end of then]
    6) should have the expected person's number of friends
     should have the expected person's friends URL


  1 passing (4s)
  6 failing

Note how the tests get underway before the then call takes place. And shortly after that, the tests start passing because by then the dynamic values have actually been loaded into the DOM. This is my problem! that page.waitForNavigation() is not waiting long enough! My first reaction was to blame Puppeteer, but I quickly realised that's daft and defensive of me, given this is the first time I've messed with this stuff, almost certainly I'm doing something wrong. Then it occurred to me that a page is navigable once the various asset files are loaded, but not necessarily when any code in them has run. Duh. I figured Puppeteer would have thought of this, so there'd be something else I could make it wait for. I googled around and found the docs for page.waitForNavigation, and indeed I needed to be doing this:

page.waitForNavigation({waitUntil: "networkidle0"})

After I did that, I found the tests still failing sometimes, but now due to a time out:

  Tests of githubProfiles page using github data
Log from client: [beginning of js]
Log from client: [before VueRouter]
Log from client: [before Vue]
Log from client: [before get call [hootlex]]
Log from client: [after get call]
Log from client: [end of js]
Log from client: [beginning of then [Alex Kyriakidis]]
Log from client: [end of then]
    1) "before all" hook: Load the page for "should have the expected person's name"


  0 passing (4s)
  1 failing

  1) Tests of githubProfiles page using github data
       "before all" hook: Load the page for "should have the expected person's name":
     Error: Timeout of 5000ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves. (/usr/share/fullstackExercise/tests/functional/public/GithubProfilesTest.js)

I had the time out set for five seconds, but now the tests are waiting for the client to finish its async call as well, I was just edging over that five second mark sometimes. So I just bumped it to 10 seconds, and thenceforth the tests all passed all the time. I've left the telemetry in for one last successful run here:

  Tests of githubProfiles page using github data
Log from client: [beginning of js]
Log from client: [before VueRouter]
Log from client: [before Vue]
Log from client: [before get call [hootlex]]
Log from client: [after get call]
Log from client: [end of js]
Log from client: [beginning of then [Alex Kyriakidis]]
Log from client: [end of then]
     should have the expected person's name
     should have the expected person's github page URL
     should have the expected person's avatar
     should have the expected person's joining year
     should have the expected person's description
     should have the expected person's number of friends
     should have the expected person's friends URL


  7 passing (5s)

OK so that was a bit of a newbie exercise, but I'm a noob so yer gonna get that. It was actually pretty fun working through it though. I'm really liking all this tooling I'm checking out ATM, so yer likely get a few more of these basic articles from me.

Righto.

--
Adam

Friday 15 January 2021

Part 8: Testing a simple web page built with Vue.js using Mocha, Chai and Puppeteer

G'day:

Familiar boilerplate about how this is one in a series follows. Please note that I initially intended this entire exercise to be a single article, but by the time I had finished the first two sections, it was way too long for a single read, so I've split it into the following sections, each as their own article:

  1. Intro / Nginx
  2. PHP
  3. PHPUnit
  4. Tweaks I made to my Bash environment in my Docker containers
  5. MariaDB
  6. Installing Symfony
  7. Using Symfony
  8. Testing a simple web page built with Vue.js using Mocha, Chai and Puppeteer (this article)
  9. I mess up how I configure my Docker containers
  10. An article about moving files and changing configuration
  11. Setting up a Vue.js project and integrating some existing code into it
  12. Unit testing Vue.js components

As indicated: this is the eighth article in the series, and follows on from Part 7: Using Symfony. Unlike the previous articles in this series, it's reasonably stand-alone: it doesn't rely on any of the Nginx / PHP / MariaDB / Symfony stuff I've written about so far. I do continue building on my Docker setup, but in an isolated fashion. So I dunno if you'd specifically benefit from going back and reading the earlier articles in the series to contextualise this article. Also as indicated in earlier articles: I'm a noob with all this stuff so this is basically a log of me working out how to get things working, rather than any sort of informed tutorial on the subject. I've said that previous sentence at the beginning of most of the articles, but it is particularly true of my exposure to Vue.js and everything I touch on in this article. One can hardly even call it "exposure": I've heard of Vue.js, I know it exists, and I have the Vue.js homepage open in the adjacent tab. That is the entirety of my exposure. Prior to this exercise I had not even heard of Puppeteer, and have only messed with Mocha and Chai very very superficially (JavaScript TDD: getting Mocha tests running on Bamboo). But such a distinct lack of knowledge has never stopped me blathering about stuff before, and I'm too old to change my ways now, so here goes.

Firstly: some full disclosure. I am going to write this article in good TDD sequence: tests first, then the web page I'm testing. In reality I did the Vue-powered web page first and then went "erm… ain't no way I'm gonna write this up without also having tests for it". Also the Vue side of things was so simple to get a "dynamic" "G'day World" page working that I hardly had anything to write about. So I'm gonna start where I ought to have, with the question of "how the hell am I going to test this?"

That question might seem really daft. My G'day World webpage looks like this:

Basically there's three testable mark-up elements: a title, heading and a paragraph all of which have "G'day World" in them. My intended Vue.js version of this will differ only in that it has "G'day world via Vue" as its intended content. When testing the flat mark-up version of this, all I need to do is curl the page, and then use a DOM parser to locate the <title>, <h1> and first <p> elements, and check their innerText values, eg:

/** @coversNothing */
public function testGdayWorldHtmlReturnsExpectedContent()
{
    $expectedContent = "G'day world";


    $client = new Client([
        'base_uri' => 'http://webserver.backend/'
    ]);

    $response = $client->get('gdayWorld.html');

    $this->assertEquals(Response::HTTP_OK, $response->getStatusCode());

    $html = $response->getBody();
    $document = new \DOMDocument();
    $document->loadHTML($html);

    $xpathDocument = new \DOMXPath($document);

    $hasTitle = $xpathDocument->query('/html/head/title[text() = "' . $expectedContent . '"]');
    $this->assertCount(1, $hasTitle);

    $hasHeading = $xpathDocument->query('/html/body/h1[text() = "' . $expectedContent . '"]');
    $this->assertCount(1, $hasHeading);

    $hasContent = $xpathDocument->query('/html/body/p[text() = "' . $expectedContent . '"]');
    $this->assertCount(1, $hasContent);
}

This approach is no good for the Vue.js-driven version of this, because the $expectedContent won't be present in the mark-up: Vue.js will be swapping it in at runtime, dynamically. All the mark-up will have in it is some placeholder like {{message}}. I can't just test for that, because it does not test that the Vue code is actually running correctly.

My first task is to find out how people are testing dynamically-created mark-up documents these days. I just googled that. Most of the material specific to Vue-managed stuff revolves around testing Vue components. This is where I hope to get to (in the next and maybe final article in this series), but for now my first "G'day world via Vue" exercise will be based on the "Hello world" example in the Vue docs, and all be inline within one HTML document, eg:

<!DOCTYPE html>
<html>
<head>
  <title>My first Vue app</title>
  <script src="https://unpkg.com/vue"></script>
</head>
<body>
  <div id="app">
    {{ message }}
  </div>

  <script>
    var app = new Vue({
      el: '#app',
      data: {
        message: 'Hello Vue!'
      }
    })
  </script>
</body>
</html>

No Vue components yet. After a bit more googling I landed on Puppeteer seeming like the way to go to test this sort of thing. From their home page:

Sounds cool.

The first thing I need to do is to add a Node.js container into my ever-growing family of containers. My initial approach was with this sorta thing in the docker/node/Dockerfile:

FROM node
WORKDIR  /usr/share/fullstackExercise/
RUN npm install puppeteer
RUN npm install mocha
RUN npm install chai
RUN npm install chai-as-promised

This would work, but I recalled something about Node.js having a package.json file, similar to Composer's composer.json (I say similar... Composer is based on NPM after all). Anyhow this seemed like a better way to go so I created one of those (with help from NPM itself, which has a wizard to generate the baseline file, via npm init). I ended up with this:

{
  "name": "fullstackexercise",
  "description": "Creating a web site with Vue.js, Nginx, Symfony on PHP8 &amp; MariaDB running in Docker containers",
  "version": "2.6.0",
  "main": "index.js",
  "directories": {
    "test": "tests"
  },
  "devDependencies": {
    "chai": "^4.2.0",
    "mocha": "^8.2.1",
    "puppeteer": "^5.5.0",
    "chai-as-promised": "^7.1.1"
  },
  "scripts": {
    "test": "mocha tests/**/*.js"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/adamcameron/fullstackExercise.git"
  },
  "author": "",
  "license": "GPL-3.0-or-later",
  "bugs": {
    "url": "https://github.com/adamcameron/fullstackExercise/issues"
  },
  "homepage": "https://github.com/adamcameron/fullstackExercise#readme"
}

That's a mix of the stuff npm init created, and the equivalent values from composer.json. This means the Dockerfile just becomes:

FROM node
WORKDIR  /usr/share/fullstackExercise/
RUN npm install

And the relevant section from docker-compose.yml:

node:
  build:
    context: ./node
  volumes:
    - ..:/usr/share/fullstackExercise
    - ./node/root_home:/root
  stdin_open: true
  tty: true
  networks:
    - backend

There's nothing unfamilar here. This all installs "fine":

adam@DESKTOP-QV1A45U:/mnt/c/src/fullstackExercise/docker$ docker-compose up --build --detach
Creating network "docker_backend" with driver "bridge"
Building nginx
[...]
Successfully built 7cb155649c3b
Successfully tagged docker_nginx:latest
Building php-fpm
[...]
Successfully built 2d3e6ba6d177
Successfully tagged docker_php-fpm:latest
Building mariadb
[...]
Successfully built 1f05ad3e3ad3
Successfully tagged docker_mariadb:latest
Building node
[...]
Step 5/5 : RUN npm install
[...]
Successfully built c9e1f709c33e
Successfully tagged docker_node:latest
Creating docker_mariadb_1 ... done
Creating docker_node_1    ... done
Creating docker_php-fpm_1 ... done
Creating docker_nginx_1   ... done
adam@DESKTOP-QV1A45U:/mnt/c/src/fullstackExercise/docker$
adam@DESKTOP-QV1A45U:/mnt/c/src/fullstackExercise/docker$ docker exec --interactive --tty docker_node_1 /bin/bash
root@33992838c2fe:/usr/share/fullstackExercise# npm list
fullstackexercise@2.6.0 /usr/share/fullstackExercise
+-- chai-as-promised@7.1.1
+-- chai@4.2.0
+-- mocha@8.2.1
`-- puppeteer@5.5.0

root@33992838c2fe:/usr/share/fullstackExercise#
root@cd18a4f3f4d4:/usr/share/fullstackExercise# node -i
Welcome to Node.js v15.5.1.
Type ".help" for more information.
>

So far so good. I've grabbed some code from the Puppeteer getting started docs, and will give that a whirl:

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');
  await page.screenshot({path: 'example.png'});

  await browser.close();
})();

I don't wanna make screenshots of web pages, but it'll do for a start:

> const puppeteer = require('puppeteer');
undefined
>
> (async () => {
...   const browser = await puppeteer.launch();
...   const page = await browser.newPage();
...   await page.goto('https://example.com');
...   await page.screenshot({path: 'example.png'});
...
...   await browser.close();
... })();
Promise { <pending> }
> Uncaught Error: Failed to launch the browser process!
/usr/share/fullstackExercise/node_modules/puppeteer/.local-chromium/linux-818858/chrome-linux/chrome: error while loading shared libraries: libnss3.so: cannot open shared object file: No such file or directory


TROUBLESHOOTING: https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md

    at onClose (/usr/share/fullstackExercise/node_modules/puppeteer/lib/cjs/puppeteer/node/BrowserRunner.js:193:20)
>

Oh. That's not what I wanted to see. I googled / stack-overflow-ed this, and yeah it seems that Puppeteer makes some assumptions about what libs are installed, and I'll be short some. I saw various different lists of stuff it was missing, and different approaches to remedying it, but for starters I figured I'd just install that lib in my container, and see what happens next. I update docker/node/Dockerfile thus:

FROM node
RUN apt-get update
RUN apt-get install libnss3-dev --yes
WORKDIR  /usr/share/fullstackExercise/
RUN npm install

After that I dropped my containers, rebuilt them, and ran that code again. I'm not expecting it to work yet, I just want to see that Puppeteer stops complaining about that library being missing:

[...]
Promise { >pending< }
> Uncaught Error: Failed to launch the browser process!
/usr/share/fullstackExercise/node_modules/puppeteer/.local-chromium/linux-818858/chrome-linux/chrome: error while loading shared libraries: libatk-bridge-2.0.so.0: cannot open shared object file: No such file or directory

It's a different library this time, so I'm calling this an "interim success". I'll rinse and repeat until I stop getting missing lib errors, and report back in a minute. Or so…

OK, so after some trial and error, I nailed it down to these missing libraries:

FROM node
RUN apt-get update
RUN apt-get install libnss3-dev libatk-bridge2.0-dev libx11-xcb1 libdrm-dev libxkbcommon-dev libgtk-3-dev libasound2-dev --yes
WORKDIR  /usr/share/fullstackExercise/
RUN npm install

All I did to track them down is to copy and paste the reference to the lib in the error and search for them on Ubuntu Packages Search. Sometimes the library name was not an exact match with the reference mentioned in the error. Anyway, this lot works for me. Given all the stuff I had read on this listed different combinations of missing packages, I'm guessing it'll all be platform- and situation-specific, so don't treat that list as canonical. Righto, this is what happens now when I run the code:

Promise { <pending> }
> Uncaught Error: Failed to launch the browser process!
[0113/143914.607623:ERROR:zygote_host_impl_linux.cc(90)] Running as root without --no-sandbox is not supported. See https://crbug.com/638180.

OK, what? I googled about and found a work around for this on Stack Overflow: Running headless Chrome / Puppeteer with --no-sandbox. It just showed me that one can pass that arg to Puppeteer, adn that makes the problem go away.

Warning

This is just a workaround and is not secure. A proper fix would be to not be running my code as root. However as this is experimental code and will never see production or be exposed externally, I'm happy to just bodge it. This is not an exercise in showing people how to secure Docker containers, and I would never want to go down that path, as I'm not qualified to discuss such things.

The code in question is now this:

const browser = await puppeteer.launch({args: ['--no-sandbox']});

And when I run this, I sat there looking at this for a while:

[...]
... })();
Promise { <pending> }
>

Nothing. Then I realised I'm a div cos this code doesn't output anything to stdout, it writes a file. So if I look in the correct place for the output, things are better:

root@00b5711ba57f:/usr/share/fullstackExercise# ll example.png
-rwxrwxrwx 1 node node 19373 Jan 13 15:12 example.png*
root@00b5711ba57f:/usr/share/fullstackExercise#

And here it is:

Perfect!

What I want to get out of this though is inspecting the actual document, so I have further updated the code to be this:

const puppeteer = require('puppeteer');

(async () => {
    const browser = await puppeteer.launch({args: ['--no-sandbox']});
    const page = await browser.newPage();
    await page.goto('https://example.com');
    const title = await page.title();
    console.info(`The title is: ${title}`);
    await browser.close();
})();

Here I'm just grabbing the title and outputing it. This will be part of my first test, so it's a reasonable next step here. And it works:

... })();
Promise { <pending> }
> The title is: Example Domain

>

I'm now content that Puppeteer works, and I have some code that executes, but I need to get this stuff into some tests now.

I've got Mocha (test framework), Chai (assertion library) and chai-as-promised (promise-oriented assertion library for Chai) installed. I'm not going to do a tutorial on running Mocha tests here, I'm just gonna look at my code. Here's my first test:

let puppeteer = require('puppeteer');

let chai = require("chai");
let chaiAsPromised = require("chai-as-promised");
chai.use(chaiAsPromised);

let should = chai.should();

describe("Baseline test of vue.js working", function () {

    this.timeout(5000);

    it("should return the correct page title", async function () {
        let browser = await puppeteer.launch( {args: ["--no-sandbox"]});
        let page = await browser.newPage();
        await page.goto("http://webserver.backend/gdayWorld.html");

        await page.title().should.eventually.equal("G'day world");

        await page.close();
        await browser.close();
    });
});

Notes:

  • This top bit is all just pulling in all the libs I need to use in the test.
  • I had to put this timeout in, as I was getting a timeout error sometimes (see error below).
  • We've already discussed this lot, it's from the Puppeteer example code.
  • For now, I am running my tests against the flat-HTML version of the file. I'm doing this because I'm kinda testing my usage of the test framework for now, and I don't have the Vue.js-driven version of the file yet.
  • And here's the assertion version of the key line in the sample code. I like this fluent syntax.
  • The eventually bit is the key bit from chai-as-promised: it handles all the async / promise carry one, and once it's done, runs the assertion. Nice.
  • And some tear down stuff. Note that I will refactor some of this stuff out from being inline in the test in the next iteration. For now I'm largely just copying and pasting the example code into a test, and tweaking it so it'll run.

And run it indeed does!

root@00b5711ba57f:/usr/share/fullstackExercise# npm test

> fullstackexercise@2.6.0 test
> mocha tests/**/*.js



  Baseline test of vue.js working
     should return the correct page title (4026ms)


  1 passing (4s)

root@00b5711ba57f:/usr/share/fullstackExercise#

I like this thing that npm did for me when I created package.json. It asks me how to run my tests, and then writes a test script for me:

"scripts": {
  "test": "mocha tests/**/*.js"
},

This is how I can just go npm test to run my tests. Very mundane to seasoned Node.js users obviously, but I am just a noob.

Oh, that timeout error I was getting:

root@00b5711ba57f:/usr/share/fullstackExercise# npm test

> fullstackexercise@2.6.0 test
> mocha tests/**/*.js



  Baseline test of vue.js working
    1) should return the correct page title


  0 passing (2s)
  1 failing

  1) Baseline test of vue.js working
       should return the correct page title:
     Error: Timeout of 2000ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves. (/usr/share/fullstackExercise/tests/functional/public/GdayWorldViaVueTest.js)
      at listOnTimeout (node:internal/timers:556:17)
      at processTimers (node:internal/timers:499:7)



npm ERR! code 1
npm ERR! path /usr/share/fullstackExercise
npm ERR! command failed
npm ERR! command sh -c mocha tests/**/*.js

npm ERR! A complete log of this run can be found in:
npm ERR!     /root/.npm/_logs/2021-01-13T16_16_36_577Z-debug.log
root@00b5711ba57f:/usr/share/fullstackExercise#

So that's why I added that timeout. Initially I thought I was doing the async stuff wrong given the hinting it was giving me, but it turned out literally to be the case that the test was taking too long to run. I guess the HTTP request and document render takes time.

Brimming with confidence, I now set out do write my other tests, and at the same time refactor the test to be a bit more sensibly organised:

let puppeteer = require('puppeteer');

let chai = require("chai");
let chaiAsPromised = require("chai-as-promised");
chai.use(chaiAsPromised);
let should = chai.should();

describe("Baseline test of vue.js working", function () {
    let browser;
    let page;

    this.timeout(5000);

    const expectedText = "G'day world";

    before (async function () {
        browser = await puppeteer.launch( {args: ["--no-sandbox"]});
        page = await browser.newPage();

        await page.goto("http://webserver.backend/gdayWorld.html");
    });

    after (async function () {
        await page.close();
        await browser.close();
    });

    it("should return the correct page title", async function () {
        await page.title().should.eventually.equal(expectedText);
    });

    it("should return the correct page heading", async function () {
        let headingText = await page.$eval("h1", headingElement => headingElement.innerText);

        headingText.should.equal(expectedText);
    });

    it("should return the correct page content", async function () {
        let paragraphContent = await page.$eval("p", paragraphElement => paragraphElement.innerText);

        paragraphContent.should.equal(expectedText);
    });
});
  • I've shifted all the Puppeteer setup/teardown into the appropriate handler functions, so they're only in there once for all three tests.
  • This really makes the tests very simple and focused.
  • For the tests needing the content of an element, I'm using this $eval method which passes the element to the callback once it's finally fetched…
  • … this way we don't need the async-handling "should.eventually code, and just should is fine.

And when I run this:

root@00b5711ba57f:/usr/share/fullstackExercise# npm test

> fullstackexercise@2.6.0 test
> mocha tests/**/*.js



  Baseline test of vue.js working
     should return the correct page title
     should return the correct page heading
     should return the correct page content


     should return the correct page heading
  3 passing (3s)

root@00b5711ba57f:/usr/share/fullstackExercise#

Wonderful. Now I'm happy my tests are doing the right thing (on the wrong file), I'm ready to implement my Vue.js version of the page.

The mark-up I have come up with has a slight change from the flat HTML version (public/gdayWorld.htm). The new file is public/gdayWorldViaVue.html:

<!doctype html>

<html lang="en">
<head>
    <meta charset="utf-8">

    <title id="title">{{ message }}</title>
</head>

<body>
<div id="app">
    <h1>{{ message }}</h1>
    <p>{{ message }}</p>
</div>
</body>
</html>

The Vue objects need to be hung off an element via an id, hence that <div id="app"> being added to handle the heading and content. But I'm gonna need a second Vue object to deal with the <title>. I presume there's a way around this, but I've not got that far yet.

Before I add the Vue part of the code, I'm going to update my tests to hit this new file, and also expect the updated text values:

const expectedText = "G'day world via Vue";

before (async function () {
    browser = await puppeteer.launch( {args: ["--no-sandbox"]});
    page = await browser.newPage();

    await page.goto("http://webserver.backend/gdayWorldViaVue.html");
});

As Vue.js is not wired in to the new file yet, the tests will fail, because the mark-up just contains the placeholder text ({{ message }}), and Vue has not rendered the dynamic values in their place yet.

root@00b5711ba57f:/usr/share/fullstackExercise# npm test

> fullstackexercise@2.6.0 test
> mocha tests/**/*.js



  Baseline test of vue.js working
    1) should return the correct page title
    2) should return the correct page heading
    3) should return the correct page content


  0 passing (3s)
  3 failing

  1) Baseline test of vue.js working
       should return the correct page title:

      AssertionError: expected '{{ message }}' to equal 'G\'day world via Vue'
      + expected - actual

      -{{ message }}
      +G'day world via Vue

      at /usr/share/fullstackExercise/node_modules/chai-as-promised/lib/chai-as-promised.js:302:22
      at processTicksAndRejections (node:internal/process/task_queues:93:5)
      at async Context.<anonymous> (tests/functional/public/GdayWorldViaVueTest.js:29:9)

  2) Baseline test of vue.js working
       should return the correct page heading:

      AssertionError: expected '{{ message }}' to equal 'G\'day world via Vue'
      + expected - actual

      -{{ message }}
      +G'day world via Vue

      at Context.<anonymous> (tests/functional/public/GdayWorldViaVueTest.js:35:28)
      at processTicksAndRejections (node:internal/process/task_queues:93:5)

  3) Baseline test of vue.js working
       should return the correct page content:

      AssertionError: expected '{{ message }}' to equal 'G\'day world via Vue'
      + expected - actual

      -{{ message }}
      +G'day world via Vue

      at Context.<anonymous> (tests/functional/public/GdayWorldViaVueTest.js:41:33)
      at processTicksAndRejections (node:internal/process/task_queues:93:5)



npm ERR! code 3
npm ERR! path /usr/share/fullstackExercise
npm ERR! command failed
npm ERR! command sh -c mocha tests/**/*.js

npm ERR! A complete log of this run can be found in:
npm ERR!     /root/.npm/_logs/2021-01-13T16_57_07_203Z-debug.log
root@00b5711ba57f:/usr/share/fullstackExercise#

This is good news: we have a failing test! Now we can do the work. The Vue.js code to do all the work is very very very simple (public/assets/scripts/gdayWorldViaVue.js):

let appData = {message: "G'day world via Vue"};
new Vue({el: '#title', data: appData});
new Vue({el: '#app', data: appData});

As per above, this hangs a Vue object off each of the #title and #app <div> elements, and binds the appData object with the message in it to them. That's it.

Back in the HTML file I need to load in Vue.js and my own script file:

</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script src="assets/scripts/gdayWorldViaVue.js"></script>
</body>
</html>

Now I can run my tests, and test that Vue.js is indeed loading the message into the title, heading and content elements:

root@00b5711ba57f:/usr/share/fullstackExercise# npm test

> fullstackexercise@2.6.0 test
> mocha tests/**/*.js



  Baseline test of vue.js working
     should return the correct page title
     should return the correct page heading
     should return the correct page content


  3 passing (3s)

root@00b5711ba57f:/usr/share/fullstackExercise#

Boom! It all works. That took a while, eh? (albeit longer to write it up and then to read about it, than to do the actual work).

For the sake of completeness, let's have a look at the page rendering in a browser. This is, after all, the "end user" requirement. Not simply that some tests pass ;-)

Perfect.

Now I admit that was an awful lot of work to test a full Vue-driven HTML document, and I won't be writing further tests like this (probably) because I'll be testing at Vue component level for the rest of the work. But I needed to get Node, Mocha and Chai all working, and - even though my initial Vue-driven test page was completely contrived - I still needed to test it.

In the next article I'll break this lot down into components and test those. All the time I will still have this test in place testing the end result expectations, and I think at least in the short term this is handy. But first I am gonna look at my green tests for a while, and have a beer. I'll be back on deck with this lot tomorrow.

Righto.

--
Adam

Thursday 14 January 2021

Part 7: Using Symfony

G'day:

Familiar boilterplate about how this is one in a series follows. Please note that I initially intended this to be a single article, but by the time I had finished the first two sections, it was way too long for a single read, so I've split it into the following sections, each as their own article:

  1. Intro / Nginx
  2. PHP
  3. PHPUnit
  4. Tweaks I made to my Bash environment in my Docker containers
  5. MariaDB
  6. Installing Symfony
  7. Using Symfony (this article)
  8. Testing a simple web page built with Vue.js using Mocha, Chai and Puppeteer
  9. I mess up how I configure my Docker containers
  10. An article about moving files and changing configuration
  11. Setting up a Vue.js project and integrating some existing code into it
  12. Unit testing Vue.js components

As indicated: this is the seventh article in the series, and follows on from Part 6: Installing Symfony. It's probably best to go have a breeze through the earlier articles first, in particular the immediately preceding one. Also as indicated in earlier articles: I'm a noob with all this stuff so this is basically a log of me working out how to get things working, rather than any sort of informed tutorial on the subject. Also I was only originally "planning" one article on getting the Symfony stuff sorted out, but yesterday's exercise was more involved that I'd've liked, so I stopped once I had Symfony serving up its welcome page, and today focusing on the config / code of getting a route, controller, model, view (actually there's no model or view in this endpoint; it's all just done in the controller) up and running.

Two caveats before I start.

Firstly: this is the first time I've done anything with Symfony other than reviewing the work of other members of my team - the ones doing the actual work. I have some familiarity with Symfony, but it's very very superficial.

Secondly, I will also be up front that I don't like Symfony's approach to MVC frameworking. Symfony is kind of a "lifestyle choice": it's opinionated, it expects you to do things a certain way, and it puts its nose into everything. This is opposed to something like Silex which simply provides the bare bones wiring to handle the ubiquitous web application requirement of guiding processing through routing, controller, model and view; other than that it just gets out of the way and is pretty invisible. I loved Silex, but sadly it's EOL so I need to move on. And, yeah, I'm being curmudgeonly and pre-judgemental as I go, I know. I do know Symfony is immensely popular, and I also know from my exposure to using its various independent libraries that it's been developed in a thoughtful, thorough manner. I expect I'm largely just being change-averse here. However I'm forewarning you now, as this will no-doubt come across in my tone, and my patience when things don't go exactly how I'd like them too (like some sort of spoilt brat). But if you've read this blog before… you probably already expect this.

Now that I have that out of my system (maybe), what's my aim for today? When I started yesterday, the intent of my work was described in this functional test:

/** @coversNothing */
public function testGreetingsEndPointReturnsPersonalisedGreeting()
{
    $testName = 'Zachary';
     $expectedGreeting = (object) [
        'name' => $testName,
        'greeting' => "G'day $testName"
    ];

    $client = new Client([
        'base_uri' => 'http://webserver.backend/'
    ]);

    $response = $client->get(
        "greetings/$testName/",
        ['http_errors' => false]
    );
    $this->assertEquals(Response::HTTP_OK, $response->getStatusCode());

    $contentTypes = $response->getHeader('Content-Type');
    $this->assertCount(1, $contentTypes);
    $this->assertSame('application/json', $contentTypes[0]);

    $responseBody = $response->getBody();
    $responseObject = json_decode($responseBody);
    $this->assertEquals($expectedGreeting, $responseObject);
}

In humanspeke, what I'm gonna do is:

  • create a route /greetings/[some name here]/;
  • return an object as JSON;
  • that confirms the name sent, plus has a greeting string for that name.

Very "Hello World". I did indicate I was not gonna be pushing the boat out too much here.

To start let's run that test, and watch it fail with a 404…

root@5565d6f15aca:/usr/share/fullstackExercise# vendor/bin/phpunit
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.

.F....                                                              6 / 6 (100%)

Time: 00:04.414, Memory: 14.00 MB

There was 1 failure:

1) adamCameron\fullStackExercise\tests\functional\SymfonyTest::testGreetingsEndPointReturnsPersonalisedGreeting
Failed asserting that 404 matches expected 200.

/usr/share/fullstackExercise/tests/functional/SymfonyTest.php:63

FAILURES!
Tests: 6, Assertions: 12, Failures: 1.

Generating code coverage report in HTML format ... done [00:01.162]
root@5565d6f15aca:/usr/share/fullstackExercise#

That one failure is the one we want to see, so that's good: I have a test failing in exactly they way I expect it to be, so now I need to work out how to add a route and all that sort of jazz. Time to RTFM. Back soon.

Right so the docs for Symfony have been incredibly helpful so far. That's not sarcasm: they've been really bloody good! I'd been looking at the Installing & Setting up the Symfony Framework page, and one of its last links is to Create your First Page in Symfony. This steps one through setting up a route and the controller that services it. The first example did not have a "runtime" parameter in the URL slug like I need here, but that was covered in another linked-to page Routing (specifically the Route Parameters section of same). That was all the information I seemed to need for my code, so off I went.

Routing is done in config/routes.yaml, and there's an example in there already. So it was easy to stick my new route in:

#index:
#    path: /
#    controller: App\Controller\DefaultController::index

greetings:
  path: /greetings/{name}
  controller: adamCameron\fullStackExercise\Controller\GreetingsController::doGet

Curly braces around the {name} just mean that that part of the URL slug is dynamic, and its value is passed to the controller method. I could (and should!) put validation on this, but that's getting ahead of myself. The current requirement I have set only handles the happy path, so we'll stick to that.

The controller goes in the src/Controller directory. I'm more accustomed to using headlessCamelCase for my namespace parts, but I'll stick with Symfony's precedent here:

namespace adamCameron\fullStackExercise\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

class GreetingsController extends AbstractController
{
    public function doGet(string $name) : Response
    {
        $greetingsResponse = [
            'name' => $name,
            'greeting' => "G'day $name"
        ];

        return new JsonResponse($greetingsResponse);
    }
}

This shows how the dynamic part of the slug from the route passes through into the controller method. Cool. From there I've gone my own way from the docs there cos Silex uses Symfony's request/response mechanism, so I know I can just return a JsonResponse like that, and it'll handle the 200-OK and the application/json part of the requirement. The docs integrate using Twig here to render some output, but there's no need for that complexity here. I suppose here the creation of $greetingsResponse is my "model", and the decision to just return a JsonResponse is my "view".

Being quite pleased with myself at how simple that was, I ran my test to verify how clever I was:

root@3fc72bf44d38:/usr/share/fullstackExercise# vendor/bin/phpunit tests/functional/SymfonyTest.php --filter=testGreetingsEndPointReturnsPersonalisedGreeting
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.

F                                                                   1 / 1 (100%)

Time: 00:02.607, Memory: 8.00 MB

There was 1 failure:

1) adamCameron\fullStackExercise\tests\functional\SymfonyTest::testGreetingsEndPointReturnsPersonalisedGreeting
Failed asserting that 500 matches expected 200.

/usr/share/fullstackExercise/tests/functional/SymfonyTest.php:63

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

Generating code coverage report in HTML format ... done [00:01.790]
root@3fc72bf44d38:/usr/share/fullstackExercise#

rrarr! OK, not so clever after all it seems. Hitting the URL in the browser gives me more information:


What is this thing on about? See where it's looking for my controller? It's looking for a class App\Controller\GreetingsController, but that class is not in App/Controller, it's in adamCameron/fullStackExercise/Controller, and that's where the route code says it is. I have re-checked everything, and it's all legit.

Sigh. I could guess what it is. Symfony being a) "clever", and b) "opinionated". If you've just read the previous article, you might recall me raising my eyebrow at this bit in composer.json:

"autoload": {
    "psr-4": {
        "App\\": "src/",
        "adamCameron\\fullStackExercise\\": "src/"
    }
},

At the time I was just observing what a dumb namespace that was, but I'm now guessing that in Symfony's opinion that is the namespace we all should be using. I remembered something about some other autowiring config that Symfony has, and guessed there was something in there that might cater to classes in the App namespace, but not other namespaces. Even if the namespaces are explicit in the code (as they are here). I located the culprit in config/services.yaml:

services:
    # default configuration for services in *this* file
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.

    # makes classes in src/ available to be used as services
    # this creates a service per class whose id is the fully-qualified class name
    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'
            - '../src/Kernel.php'
            - '../src/Tests/'

    # controllers are imported separately to make sure services can be injected
    # as action arguments even if you don't extend any base controller class
    App\Controller\:
        resource: '../src/Controller/'
        tags: ['controller.service_arguments']

    # add more service definitions when explicit configuration is needed
    # please note that last definitions always *replace* previous ones

So there's magic taking place for App; I guess I need to make it magical for my actual namespace instead. I'm never gonna write code with App as its namespace, so I'm just gonna punt that I can change that to adamCameron\fullStackExercise\: and adamCameron\fullStackExercise\Controller\: and it'll all be fine. I think ATM I only need to monkey with the Controller one, but I might as well do both now I guess. So with that change in place, I re-run the test:

root@3fc72bf44d38:/usr/share/fullstackExercise# vendor/bin/phpunit tests/functional/SymfonyTest.php --filter=testGreetingsEndPointReturnsPersonalisedGreeting
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 00:06.440, Memory: 8.00 MB

OK (1 test, 4 assertions)

Generating code coverage report in HTML format ... done [00:01.825]
root@3fc72bf44d38:/usr/share/fullstackExercise#

All right, that was easily located / solved and is not the end of the world. However this from Symfony's error message irks me: "The file was found but the class was not in it, the class name or namespace probably has a typo". No mate. That's not the problem at all. The problem is that despite me giving you the exact fully-qualified namespaced class name in the route config, and the namespace in the class file was correct, and in the right place in the file system… and if you just left things be then you would have found the correct class. But: no. You had to try to be clever, and you failed miserably. I really frickin' hate it when technology goes "nono [patronising grimace] I've got this, I'll do it for you" (I know some devs like this too…), and it gets it wrong. I'm all for Symfony having the helpers there should one want them, but it shouldn't make these assumptions. I guess had it gone "hey aaah… that namespace yer using for yer controller? Ya need to configure that to work mate. Go have a look in config.yaml", then I'd just be going "yeah nice one: thanks for that". But then again most people would probably not have written this ranty paragraph at all, and just moved on with their lives, eh? Hey at least I'm self-aware.

All in all, even with the config/namespace hiccup, getting a simple route working was pretty bloody easy here. And this is a good thing about Symfony.

That was actually pretty short. It was about 500 words and some test-runs longer, as I had encountered a weird issue which I was moaning about. However when doing a final read-through of this before pressing "send", I looked at it some more, and it turns out it was just me being a div, and the problem was firmly of the PEBCAK variety. So… erm… I decided to delete that bit. Ahem.

Next I have to start doing some front-end-ish stuff so I've got an excuse to try out Vue.js. This should be interesting. I've never even looked @ Vue.js before. I bet I'll disagree with some of its opinions eh? Stay tuned…

Righto.

--
Adam