A string is equal to itself
A developer could later make a trivial change to the message, such as “last name must not be null” and this would break the unit tests, even though the message's meaning is still correct.
Exactly. The goal of unit tests is to test the actual logic. Testing that a string is equal to itself is useless. I would even argue that as soon as you repeat the message in your test, you introduce code duplication, that you should solve by moving the string to a constant (and reuse the constant in the test). As soon as you do this, it would become obvious that the test has zero value, as it would simply assert that the constant is equal to itself.
Testing actual messages becomes valuable when the messages are not simple constants, but are built based on a logic. This logic, indeed, deserves tests, as any other logic. It's up to you to evaluate the benefits of such tests, versus the cost of adding a test.
Note that in some circumstances,¹ exception messages are also localized. That is, on a German PC, the exception will be in German, whereas on a Japanese machine, they'll show in Japanese. Usually, you won't test every message in every supported locale. However, it is not unusual to test the formatting, when placeholders are used, as a message that has placeholders {0} and {1} in one language may have them inverted, i.e. {1} and {0}, in another language, or may require some very specific logic in another culture (such as when pluralizing things).
Enrich your exceptions
if sayHello("Tom", null) returns an IllegalArgumentException with message “first name is null,” I would consider that a bug, and I would like a unit test to catch this.
Then test exactly that. Asserting contents of an exception message is a rather convoluted way to do the test.
The exception message should contain all it needs to construct the actual message later, if needed. The documentation seems to imply that IllegalArgumentException takes only one argument—the actual message. This makes it inconvenient to use. By comparison, in .NET, you have an ArgumentNullException. While it can take an optional custom message as an argument, the usual way to use it is to pass just the name of the actual argument:
if (firstName == null)
{
throw new ArgumentNullException(nameof(firstName));
}
When the name is null, the message would be generated automatically, looking like this:
Value cannot be null. (Parameter 'firstName')
When testing, you'll assert that ex.ParamName is equal to a given string. How the parameter name is then translated into an actual exception message is the job of .NET, not yours.
Doing this has an additional benefit of saving memory. You don't need the full string to be stored here: as the logic is idempotent, the actual message can be built on demand, when and if the message is needed. It might happen that the exception will be swallowed anyway by a try/catch block.
If Java doesn't have an exception type that let you do that, you may create one.
Additional things to think about
When designing exception messages, take your time to do several other things:
Make absolutely sure that the exception is clear and complete. Imagine yourself on Friday evening in front of a log file that tells you that (1) the application crashed, and (2) it's because “first name is null.” By the way, there is an ersatz of stack trace that you can't easily use, the application being built in release mode. Does such message help you in your debugging? Does it make your life easier?
Exception messages are messages, and so they should use proper English, proper punctuation, start with a capital letter, etc.
Exception messages are for developers, and developers only (and support persons). Not for the end users. This means that if your application has an actual interface, you should take great care to avoid the situation where the value ends up being null in the first place—and if you don't, you should present the user with a clear, readable message. It's at this level that you'll have quite a lot of tests, as making such messages is all but easy.
It's fine to have a check that the argument is not null. However, one should note that other languages have better alternatives. Some have richer types, where you can tell that you don't want a null value to be passed. And if the code that calls the method tries to pass a null, it won't compile (as your Java code won't compile if you would call your method and try to pass a float and a boolean as arguments). Others have code contracts—contracts that are part of the public interface of the method and that can be statically checked.
¹ For instance, .NET localizes the messages of the exceptions thrown by .NET itself. IMHO, this is a mistake, as exceptions have no purpose being shown to the end users—Microsoft's own products are a good example of why, when they occasionally crash with absolutely unhelpful exception messages. Nevertheless, the practice exists.