All relative paths are evaluated relatively to the current working directory. If you are using a shell, the working directory is often shown in your shell prompt. There is no link between your working directory and the directory of the currently processed file. This behavior explains all of your problems:
Since gradlew is on project root, it is invoked as ../gradlew clean which is one level above the backend_test.sh file -
On CI servers, all processes are usually invoked using the root directory as working directory (I'm not sure about GitLab CI, but I guess that it behaves this way, too). Now ../gradlew will be evaluated relatively to your root directory, which won't work, since it searches for gradlew in the parent directory of whatever GitLab CI uses as a temporary folder. You can check this behavior by locally navigating a shell to your project directory and then invoking test-runners/backend_test.sh, it should result in the same error.
If I move backend_test.sh also on root level and update shell file to invoke gradlew as ./gradlew clean then there is no error.
Well, of course this works as long as the script will be invoked from your project directory, which is the default on CI servers, as we already noticed. Locally you can simply navigate to your project directory and just invoke backend_test.sh.
Running shell locally i.e. ./backend_test.sh gets me following error
Well, now we got the same problem the other way around. Your script can find ../gradlew and Gradle is invoked, but sadly, Gradle will evaluate the working directory to find the relevant build.gradle file. But now the working directory is inside your test-runners folder and Gradle will search this folder for a build.gradle file. If there is no build.gradle file inside that folder, Gradle does not care at all, instead it just assumes there is an empty build.gradle file. Now Gradle uses this (empty) project to run the build, but since there is no task clean in the (empty) project, Gradle fails.