How exercises work here

Station 0.6 10 min Interchange: PHPUnit → go test

You've passed a few exercises already, so you know the drill: write some Go, click Submit, get ticks or crosses. This last Orientation stop opens the hood. What happens to your code, what the tests look like, and how to read a failure quickly, because from Unit 1 onwards the tests get pickier.

What happens when you click Submit

Your code isn't run in your browser, and it isn't faked. It goes to a sandbox that runs the real Go toolchain:

flowchart TB
  accTitle: What happens when you click Submit
  accDescr: The editor sends only your files to the web app, which forwards them to the runner. The runner adds the hidden test files, starts a brand-new container with no network access, and runs go test. Results for each test come back to the page.
  you([Your code in the editor]) --> app[gobyphp web app]
  app --> runner[Runner service]
  runner --> add[Adds the hidden _test.go files]
  add --> box[Brand-new container: no network, time and memory limits]
  box --> test{{go test}}
  test --> results([Ticks and crosses, per test])

A few things follow from that:

  • Run does go run . and shows what your program prints. Submit does go test against the hidden tests. Neither touches your machine.
  • Each run gets a fresh container, so nothing carries over between submissions. No network means http.Get to the outside world will fail, by design.
  • Your code autosaves in your browser, so a refresh won't lose it.
  • Hints unlock one at a time. After three failed submits you can reveal the solution: the exercise still counts, but your certificate will say how many you peeked at. No judgement. Everyone peeks.
You, 23:47"It works on my machine."
The runner, 23:47Fresh container. No network. No leftovers from last time.
You, 23:48"Ah. It works on a machine."
Every Submit boards a brand-new train.

Go tests, for PHPUnit people

The exercise below shows you its tests (most don't). Here's what you'll see:

  • Tests live next to the code, in files ending in _test.go. There's no tests/ directory and no phpunit.xml.
  • A test is any function named TestSomething(t *testing.T). No classes, no extends TestCase.
  • There's no assertion library. No assertSame, no assertCount. A test is an ordinary if that calls t.Errorf when something's wrong. (t.Fatalf does the same but stops the test right there.)
  • Table-driven tests are Go's data providers: a slice of cases, looped over, each run as a named subtest with t.Run. That's why the results show names like TestMinutes/partial_minutes_round_up.

Reading a failure

Every failure follows the same shape:

// --- FAIL: TestMinutes/partial_minutes_round_up
//     main_test.go:22: Minutes(90) = 1, want 2

From left to right: which test and case failed, the file and line in the test where it was spotted, what was called, what you returned, and what was wanted. The subtest name usually tells you the rule you broke. Here, part-minutes should round up.

Mind the gap

Go's convention is got, then want: Minutes(90) = 1, want 2 means your code returned 1. PHPUnit puts things the other way round, assertSame($expected, $actual), and its output says "Failed asserting that 1 is identical to 2". After years of PHPUnit, it's easy to read Go's message backwards. The value next to the function call is yours.

Recap

  • Submit runs go test with hidden tests in a fresh, network-less container. Run runs go run ..
  • Go tests are plain functions in _test.go files: an if, then t.Errorf("got …, want …"). Table-driven subtests replace data providers.
  • Read failures as test/case → file:line → call = got, want …. The subtest name tells you the rule.

1A test, with data

PHPMinutesTest.php
<?php
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class MinutesTest extends TestCase
{
    #[DataProvider('cases')]
    public function testMinutes(int $seconds, int $expected): void
    {
        $this->assertSame($expected, minutes($seconds));
    }

    public static function cases(): array
    {
        return [
            'whole minutes' => [120, 2],
            'partial minutes round up' => [90, 2],
        ];
    }
}
GOminutes_test.go
func TestMinutes(t *testing.T) {
	tests := []struct {
		name    string
		seconds int
		want    int
	}{
		{"whole_minutes", 120, 2},
		{"partial_minutes_round_up", 90, 2},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Minutes(tt.seconds); got != tt.want {
				t.Errorf("Minutes(%d) = %d, want %d", tt.seconds, got, tt.want)
			}
		})
	}
}

Mind the gap

No assertion library: an if and t.Errorf, with got before want. The case names become subtest names in the output.

2Running tests

PHPphpunit.sh
vendor/bin/phpunit                        # everything
vendor/bin/phpunit --filter testMinutes   # one test
vendor/bin/phpunit --testdox              # readable names
GOgotest.sh
go test ./...                             # everything
go test -run 'TestMinutes/partial' ./...  # one test, or one case
go test -v ./...                          # list every test as it runs

3Dividing whole numbers

PHPdivide.php
<?php
echo 90 / 60;          // 1.5
echo intdiv(90, 60);   // 1
echo ceil(90 / 60);    // 2   (how you'd round up)
GOdivide.go
fmt.Println(90 / 60)     // 1    (int ÷ int is an int)
fmt.Println(90.0 / 60.0) // 1.5  (floats stay floats)

Mind the gap

Dividing one int by another gives an int in Go, and the remainder is thrown away. PHP quietly gives you a float, which is why ceil() works there. Go has no ceil for ints, and that's the bug in this lesson's exercise.